scan-skill 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. scan_core/__init__.py +101 -0
  2. scan_core/__main__.py +7 -0
  3. scan_core/auxiliary.py +139 -0
  4. scan_core/baseline.py +88 -0
  5. scan_core/builder.py +538 -0
  6. scan_core/cli.py +342 -0
  7. scan_core/compare.py +176 -0
  8. scan_core/config.py +101 -0
  9. scan_core/core.py +293 -0
  10. scan_core/doctor.py +223 -0
  11. scan_core/errors.py +52 -0
  12. scan_core/git_change.py +214 -0
  13. scan_core/git_provenance.py +213 -0
  14. scan_core/ids.py +43 -0
  15. scan_core/inventory.py +305 -0
  16. scan_core/privacy.py +391 -0
  17. scan_core/render.py +368 -0
  18. scan_core/report/__init__.py +1 -0
  19. scan_core/report/shell.html +142 -0
  20. scan_core/reuse.py +214 -0
  21. scan_core/scanbench.py +473 -0
  22. scan_core/schema.py +31 -0
  23. scan_core/schemas/__init__.py +1 -0
  24. scan_core/schemas/scan-state-1.2.schema.json +26 -0
  25. scan_core/schemas/scan-state-1.3.schema.json +29 -0
  26. scan_core/schemas/scan-state-1.4.schema.json +58 -0
  27. scan_core/settings.py +133 -0
  28. scan_core/snapshot.py +48 -0
  29. scan_core/state.py +477 -0
  30. scan_core/validate.py +847 -0
  31. scan_core/version.py +3 -0
  32. scan_skill-0.3.1.dist-info/METADATA +286 -0
  33. scan_skill-0.3.1.dist-info/RECORD +101 -0
  34. scan_skill-0.3.1.dist-info/WHEEL +5 -0
  35. scan_skill-0.3.1.dist-info/entry_points.txt +2 -0
  36. scan_skill-0.3.1.dist-info/licenses/LICENSE +21 -0
  37. scan_skill-0.3.1.dist-info/top_level.txt +2 -0
  38. scanbench_data/README.md +11 -0
  39. scanbench_data/__init__.py +1 -0
  40. scanbench_data/fixtures/__init__.py +1 -0
  41. scanbench_data/fixtures/archive-boundary/README.md +5 -0
  42. scanbench_data/fixtures/archive-boundary/expected.json +30 -0
  43. scanbench_data/fixtures/archive-boundary/fixture.json +8 -0
  44. scanbench_data/fixtures/archive-boundary/project/README.txt +1 -0
  45. scanbench_data/fixtures/archive-boundary/project/bundle.zip +0 -0
  46. scanbench_data/fixtures/clean-minimal/README.md +5 -0
  47. scanbench_data/fixtures/clean-minimal/expected.json +39 -0
  48. scanbench_data/fixtures/clean-minimal/fixture.json +6 -0
  49. scanbench_data/fixtures/clean-minimal/project/app.txt +1 -0
  50. scanbench_data/fixtures/false-runtime-claim/README.md +5 -0
  51. scanbench_data/fixtures/false-runtime-claim/expected.json +34 -0
  52. scanbench_data/fixtures/false-runtime-claim/fixture.json +6 -0
  53. scanbench_data/fixtures/false-runtime-claim/project/status.txt +1 -0
  54. scanbench_data/fixtures/false-runtime-claim/project/test_example.py +2 -0
  55. scanbench_data/fixtures/generated-and-vendor/README.md +5 -0
  56. scanbench_data/fixtures/generated-and-vendor/expected.json +24 -0
  57. scanbench_data/fixtures/generated-and-vendor/fixture.json +6 -0
  58. scanbench_data/fixtures/generated-and-vendor/project/dist/app.js +1 -0
  59. scanbench_data/fixtures/generated-and-vendor/project/src/main.js +1 -0
  60. scanbench_data/fixtures/generated-and-vendor/project/vendor/lib.js +1 -0
  61. scanbench_data/fixtures/hidden-files/README.md +5 -0
  62. scanbench_data/fixtures/hidden-files/expected.json +19 -0
  63. scanbench_data/fixtures/hidden-files/fixture.json +6 -0
  64. scanbench_data/fixtures/hidden-files/project/.hidden.txt +1 -0
  65. scanbench_data/fixtures/hidden-files/project/visible.txt +1 -0
  66. scanbench_data/fixtures/incomplete-discovery/README.md +5 -0
  67. scanbench_data/fixtures/incomplete-discovery/expected.json +29 -0
  68. scanbench_data/fixtures/incomplete-discovery/fixture.json +8 -0
  69. scanbench_data/fixtures/incomplete-discovery/project/a.txt +1 -0
  70. scanbench_data/fixtures/incomplete-discovery/project/b.txt +1 -0
  71. scanbench_data/fixtures/inventory-basic/expected.json +3 -0
  72. scanbench_data/fixtures/malformed-filename/README.md +5 -0
  73. scanbench_data/fixtures/malformed-filename/expected.json +24 -0
  74. scanbench_data/fixtures/malformed-filename/fixture.json +6 -0
  75. scanbench_data/fixtures/malformed-filename/project/-rf.txt +1 -0
  76. scanbench_data/fixtures/malformed-filename/project/space name.txt +1 -0
  77. scanbench_data/fixtures/malformed-filename/project/unicode-/342/225/254/342/225/227.txt +1 -0
  78. scanbench_data/fixtures/prompt-injection/README.md +5 -0
  79. scanbench_data/fixtures/prompt-injection/expected.json +28 -0
  80. scanbench_data/fixtures/prompt-injection/fixture.json +8 -0
  81. scanbench_data/fixtures/prompt-injection/project/instructions.txt +1 -0
  82. scanbench_data/fixtures/secret-redaction/README.md +5 -0
  83. scanbench_data/fixtures/secret-redaction/expected.json +29 -0
  84. scanbench_data/fixtures/secret-redaction/fixture.json +9 -0
  85. scanbench_data/fixtures/secret-redaction/project/public.txt +1 -0
  86. scanbench_data/fixtures/secret-redaction/project/secrets.env +1 -0
  87. scanbench_data/fixtures/symlink-escape/README.md +5 -0
  88. scanbench_data/fixtures/symlink-escape/expected.json +31 -0
  89. scanbench_data/fixtures/symlink-escape/fixture.json +13 -0
  90. scanbench_data/fixtures/symlink-escape/project/inside.txt +1 -0
  91. scanbench_data/fixtures/unreadable-artifact/README.md +5 -0
  92. scanbench_data/fixtures/unreadable-artifact/expected.json +30 -0
  93. scanbench_data/fixtures/unreadable-artifact/fixture.json +8 -0
  94. scanbench_data/fixtures/unreadable-artifact/project/locked.txt +1 -0
  95. scanbench_data/fixtures/unreadable-artifact/project/readable.txt +1 -0
  96. scanbench_data/fixtures/unsupported-binary/README.md +5 -0
  97. scanbench_data/fixtures/unsupported-binary/expected.json +26 -0
  98. scanbench_data/fixtures/unsupported-binary/fixture.json +6 -0
  99. scanbench_data/fixtures/unsupported-binary/project/blob.bin +0 -0
  100. scanbench_data/fixtures/unsupported-binary/project/note.txt +1 -0
  101. scanbench_data/manifest.json +24 -0
scan_core/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """Public Scan Core API.
2
+
3
+ The v0.2 line established deterministic inventory, canonical state, privacy,
4
+ self-contained rendering and ScanBench. The v0.3 line adds repeat-scan state
5
+ comparison, local baselines, strict evidence freshness/reuse semantics and local Git change context.
6
+ """
7
+
8
+ from .builder import (
9
+ ActionRecord,
10
+ BuilderError,
11
+ CheckRecord,
12
+ ComponentRecord,
13
+ ControlRecord,
14
+ EvidenceRecord,
15
+ FindingRecord,
16
+ RelationshipRecord,
17
+ SemanticStateBuilder,
18
+ UnknownRecord,
19
+ upgrade_semantic_state,
20
+ )
21
+ from .compare import Comparison, compare_states
22
+ from .config import ScanConfig, ScanResult
23
+ from .core import scan_project
24
+ from .doctor import DoctorCheck, DoctorResult, run_doctor
25
+ from .inventory import InventoryArtifact, InventoryResult, build_inventory
26
+ from .privacy import (
27
+ EgressPreview,
28
+ PrivacyAssessment,
29
+ RedactionPolicy,
30
+ RedactionResult,
31
+ assess_privacy,
32
+ build_egress_preview,
33
+ prepare_text_for_egress,
34
+ redact_value,
35
+ )
36
+ from .render import render_html, render_report
37
+ from .reuse import ReuseDecision, ReusePolicy, SemanticCheckDefinition, SemanticRegistration, evaluate_reuse
38
+ from .scanbench import BenchResult, run_bench
39
+ from .schema import schema_object, schema_text
40
+ from .settings import ProjectSettings, load_project_settings
41
+ from .snapshot import Snapshot, build_snapshot, canonical_manifest
42
+ from .state import build_state, canonical_state_json, configuration_digest, state_digest
43
+ from .validate import validate_path, validate_report, validate_state
44
+ from .version import __version__
45
+
46
+ __all__ = [
47
+ "ActionRecord",
48
+ "BenchResult",
49
+ "BuilderError",
50
+ "CheckRecord",
51
+ "ComponentRecord",
52
+ "ControlRecord",
53
+ "DoctorCheck",
54
+ "DoctorResult",
55
+ "Comparison",
56
+ "EgressPreview",
57
+ "EvidenceRecord",
58
+ "FindingRecord",
59
+ "InventoryArtifact",
60
+ "InventoryResult",
61
+ "PrivacyAssessment",
62
+ "ProjectSettings",
63
+ "ReuseDecision",
64
+ "ReusePolicy",
65
+ "RedactionPolicy",
66
+ "RedactionResult",
67
+ "ScanConfig",
68
+ "ScanResult",
69
+ "SemanticStateBuilder",
70
+ "SemanticCheckDefinition",
71
+ "SemanticRegistration",
72
+ "Snapshot",
73
+ "RelationshipRecord",
74
+ "UnknownRecord",
75
+ "assess_privacy",
76
+ "build_egress_preview",
77
+ "build_inventory",
78
+ "build_snapshot",
79
+ "build_state",
80
+ "canonical_manifest",
81
+ "canonical_state_json",
82
+ "compare_states",
83
+ "configuration_digest",
84
+ "evaluate_reuse",
85
+ "load_project_settings",
86
+ "prepare_text_for_egress",
87
+ "redact_value",
88
+ "render_html",
89
+ "render_report",
90
+ "run_bench",
91
+ "run_doctor",
92
+ "scan_project",
93
+ "schema_object",
94
+ "schema_text",
95
+ "state_digest",
96
+ "upgrade_semantic_state",
97
+ "validate_path",
98
+ "validate_report",
99
+ "validate_state",
100
+ "__version__",
101
+ ]
scan_core/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow `python -m scan_core` during development and troubleshooting."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
scan_core/auxiliary.py ADDED
@@ -0,0 +1,139 @@
1
+ """Bounded auxiliary reads within an explicitly authorized directory.
2
+
3
+ Metadata links/reparse points are rejected, including internal links. Git worktree
4
+ indirection is resolved separately and never grants access to arbitrary parents.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import ntpath
10
+ from pathlib import Path, PureWindowsPath
11
+ import stat
12
+ from contextlib import contextmanager
13
+ from typing import Iterator
14
+
15
+
16
+ class AuxiliaryReadError(ValueError):
17
+ """Metadata was missing, oversized, or outside the authorized boundary."""
18
+
19
+
20
+ def _windows_path_key(value: str | Path) -> str:
21
+ """Compare canonical Win32 paths using the same namespace and separators."""
22
+ value = str(value).replace("/", "\\")
23
+ if value[:8].lower() == "\\\\?\\unc\\":
24
+ value = "\\\\" + value[8:]
25
+ elif value.startswith("\\\\?\\"):
26
+ value = value[4:]
27
+ return ntpath.normcase(ntpath.normpath(value))
28
+
29
+
30
+ def is_link_or_reparse(metadata: os.stat_result) -> bool:
31
+ return stat.S_ISLNK(metadata.st_mode) or bool(
32
+ getattr(metadata, "st_file_attributes", 0) & 0x400
33
+ )
34
+
35
+
36
+ def auxiliary_path(root: Path, relative: str | Path) -> Path:
37
+ """Validate lexical scope and every component before a metadata operation."""
38
+ raw = str(relative).replace("\\", "/")
39
+ windows = PureWindowsPath(raw)
40
+ if not raw or Path(raw).is_absolute() or windows.drive or windows.root or ".." in raw.split("/"):
41
+ raise AuxiliaryReadError("absolute or parent metadata traversal rejected")
42
+ # Colons can name Windows alternate data streams even in a relative path.
43
+ if ":" in raw or "\x00" in raw:
44
+ raise AuxiliaryReadError("invalid metadata path")
45
+ try:
46
+ boundary = root.absolute()
47
+ if is_link_or_reparse(boundary.lstat()):
48
+ raise AuxiliaryReadError("metadata root is a link/reparse point")
49
+ candidate = boundary
50
+ for part in Path(raw).parts:
51
+ candidate /= part
52
+ if is_link_or_reparse(candidate.lstat()):
53
+ raise AuxiliaryReadError("metadata link/reparse point rejected")
54
+ candidate.resolve(strict=True).relative_to(boundary.resolve(strict=True))
55
+ return candidate
56
+ except (OSError, ValueError, RuntimeError) as error:
57
+ if isinstance(error, AuxiliaryReadError):
58
+ raise
59
+ raise AuxiliaryReadError("metadata missing or outside authorized root") from error
60
+
61
+
62
+ @contextmanager
63
+ def _open_metadata(root: Path, path: Path, flags: int) -> Iterator[int]:
64
+ if os.name == 'nt':
65
+ # Validate the opened handle's actual path BEFORE reading bytes. Checking
66
+ # path.resolve() alone leaves a parent-junction replacement race.
67
+ import ctypes
68
+ import msvcrt
69
+ # Pin the intended canonical boundary/target BEFORE os.open can follow
70
+ # a replaced parent. resolve() also expands native short-name aliases.
71
+ boundary = root.resolve(strict=True)
72
+ expected = path.resolve(strict=True)
73
+ if not expected.is_relative_to(boundary):
74
+ raise AuxiliaryReadError('metadata escaped its authorized root before opening')
75
+ descriptor = os.open(path, flags)
76
+ try:
77
+ get_path = ctypes.windll.kernel32.GetFinalPathNameByHandleW
78
+ get_path.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_uint32, ctypes.c_uint32]
79
+ get_path.restype = ctypes.c_uint32
80
+ buffer = ctypes.create_unicode_buffer(32768)
81
+ length = get_path(msvcrt.get_osfhandle(descriptor), buffer, len(buffer), 0)
82
+ if not length or length >= len(buffer):
83
+ raise AuxiliaryReadError('opened metadata path could not be verified')
84
+ actual = Path(_windows_path_key(buffer.value)).resolve(strict=True)
85
+ if (_windows_path_key(actual) != _windows_path_key(expected)
86
+ or not actual.is_relative_to(boundary)):
87
+ raise AuxiliaryReadError('opened metadata escaped its authorized path')
88
+ yield descriptor
89
+ finally:
90
+ os.close(descriptor)
91
+ return
92
+
93
+ # POSIX traverses from a pinned directory handle without following links in
94
+ # any child component, even when an attacker replaces a parent concurrently.
95
+ directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
96
+ directory = os.open(root.absolute(), directory_flags)
97
+ try:
98
+ parts = path.relative_to(root.absolute()).parts
99
+ for part in parts[:-1]:
100
+ child = os.open(part, directory_flags, dir_fd=directory)
101
+ os.close(directory)
102
+ directory = child
103
+ descriptor = os.open(parts[-1], flags | os.O_NOFOLLOW, dir_fd=directory)
104
+ try:
105
+ yield descriptor
106
+ finally:
107
+ os.close(descriptor)
108
+ finally:
109
+ os.close(directory)
110
+
111
+
112
+ def read_auxiliary(root: Path, relative: str | Path, *, max_bytes: int) -> bytes:
113
+ """Read at most max_bytes + one sentinel; never use an unbounded read."""
114
+ if max_bytes < 0:
115
+ raise ValueError("max_bytes must be non-negative")
116
+ path = auxiliary_path(root, relative)
117
+ try:
118
+ before = path.lstat()
119
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
120
+ with _open_metadata(root, path, flags) as descriptor, os.fdopen(descriptor, "rb", closefd=False) as stream:
121
+ opened = os.fstat(stream.fileno())
122
+ if not stat.S_ISREG(opened.st_mode) or (before.st_dev, before.st_ino) != (opened.st_dev, opened.st_ino):
123
+ raise AuxiliaryReadError("metadata changed or is not a regular file")
124
+ if opened.st_size > max_bytes:
125
+ raise AuxiliaryReadError("metadata exceeds byte limit")
126
+ data = stream.read(max_bytes + 1)
127
+ auxiliary_path(root, relative)
128
+ if len(data) > max_bytes:
129
+ raise AuxiliaryReadError("metadata exceeds byte limit")
130
+ return data
131
+ except OSError as error:
132
+ raise AuxiliaryReadError("metadata could not be read safely") from error
133
+
134
+
135
+ def auxiliary_text(root: Path, relative: str | Path, *, max_bytes: int) -> str | None:
136
+ try:
137
+ return read_auxiliary(root, relative, max_bytes=max_bytes).decode("utf-8", errors="replace")
138
+ except AuxiliaryReadError:
139
+ return None
scan_core/baseline.py ADDED
@@ -0,0 +1,88 @@
1
+ """Local-only baseline resolution for repeated Scan runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Mapping
9
+
10
+ from .errors import ResourceLimitError, SourceError, ValidationError
11
+ from .validate import reject_constant, unique_keys, validate_state
12
+
13
+ DEFAULT_MAX_STATE_BYTES = 64_000_000
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class BaselineResolution:
18
+ mode: str
19
+ status: str
20
+ state: Mapping[str, Any] | None = None
21
+ warning: str | None = None
22
+
23
+
24
+ def load_state_file(path: Path, *, max_state_bytes: int = DEFAULT_MAX_STATE_BYTES, explicit: bool = True) -> dict[str, Any]:
25
+ try:
26
+ stat = path.stat()
27
+ except OSError as error:
28
+ raise SourceError(f"Cannot access baseline state: {path}") from error
29
+ if not path.is_file():
30
+ raise SourceError(f"Baseline state is not a regular file: {path}")
31
+ if stat.st_size > max_state_bytes:
32
+ message = f"Baseline state exceeds configured ceiling ({stat.st_size} > {max_state_bytes} bytes)"
33
+ if explicit:
34
+ raise ResourceLimitError(message)
35
+ raise ValidationError(message)
36
+ try:
37
+ with path.open(encoding="utf-8") as stream:
38
+ state = json.load(stream, object_pairs_hook=unique_keys, parse_constant=reject_constant)
39
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
40
+ raise ValidationError(f"Cannot parse baseline state: {error}") from error
41
+ if not isinstance(state, dict):
42
+ raise ValidationError("Baseline state must be a JSON object")
43
+ if state.get("schema_version") not in {"1.3", "1.4"}:
44
+ raise ValidationError("Baseline state must use schema_version 1.3 or 1.4")
45
+ validate_state(state)
46
+ return state
47
+
48
+
49
+ def resolve_baseline(
50
+ *,
51
+ mode: str,
52
+ output_state: Path | None,
53
+ explicit_path: Path | None = None,
54
+ max_state_bytes: int = DEFAULT_MAX_STATE_BYTES,
55
+ ) -> BaselineResolution:
56
+ """Resolve a local baseline without network access or filesystem searching."""
57
+ if mode == "none":
58
+ return BaselineResolution(mode="none", status="disabled")
59
+ if mode == "explicit":
60
+ if explicit_path is None:
61
+ raise SourceError("Explicit baseline path was not supplied")
62
+ path = Path(explicit_path)
63
+ if not path.exists():
64
+ raise SourceError(f"Baseline state does not exist: {path}")
65
+ state = load_state_file(path, max_state_bytes=max_state_bytes, explicit=True)
66
+ return BaselineResolution(mode="explicit", status="used", state=state)
67
+ if mode != "auto":
68
+ raise ValueError(f"Unsupported baseline mode: {mode}")
69
+ if output_state is None:
70
+ return BaselineResolution(mode="auto", status="not_found")
71
+ path = Path(output_state)
72
+ if not path.exists():
73
+ return BaselineResolution(mode="auto", status="not_found")
74
+ try:
75
+ state = load_state_file(path, max_state_bytes=max_state_bytes, explicit=False)
76
+ except ValidationError as error:
77
+ return BaselineResolution(
78
+ mode="auto",
79
+ status="invalid",
80
+ warning=f"Automatic baseline ignored: {error}",
81
+ )
82
+ except SourceError as error:
83
+ return BaselineResolution(
84
+ mode="auto",
85
+ status="invalid",
86
+ warning=f"Automatic baseline ignored: {error}",
87
+ )
88
+ return BaselineResolution(mode="auto", status="used", state=state)