scamscript 0.5.0__tar.gz → 0.6.1__tar.gz
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.
- {scamscript-0.5.0 → scamscript-0.6.1}/.github/workflows/release.yml +0 -23
- {scamscript-0.5.0 → scamscript-0.6.1}/CHANGELOG.md +21 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/PKG-INFO +18 -3
- {scamscript-0.5.0 → scamscript-0.6.1}/README.md +17 -2
- {scamscript-0.5.0 → scamscript-0.6.1}/pyproject.toml +1 -1
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/__init__.py +1 -1
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/analysis.py +4 -1
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/cli.py +30 -3
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/client.py +1 -1
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/render.py +2 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/report.py +38 -2
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/store.py +19 -1
- scamscript-0.6.1/src/scamscript/taxonomy.py +77 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/test_cli.py +24 -1
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/test_report.py +14 -1
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/test_store.py +10 -0
- scamscript-0.6.1/tests/test_taxonomy.py +51 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/.github/SECURITY.md +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/.github/pull_request_template.md +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/.github/workflows/ci.yml +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/.gitignore +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/CITATION.cff +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/CODE_OF_CONDUCT.md +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/CONTRIBUTING.md +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/LICENSE +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/docs/banner.svg +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/docs/calibration.md +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/docs/demo.gif +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/docs/sample-report.md +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/docs/social-preview.png +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/adversarial/fake_output_json.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/adversarial/fence_escape.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/adversarial/fixed_attack_type.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/adversarial/non_json_demand.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/adversarial/print_system_prompt.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/adversarial/role_switch.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/adversarial/score_override.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/crude_robocall.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/gov_impersonation_vishing.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/prompt_injection_script.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/romance_scam_sms.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/sophisticated_wire_fraud.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/examples/tech_support_scam.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/scripts/calibrate.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/scripts/injection_eval.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/__main__.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/config.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/dedupe.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/ingest.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/models.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/prompts.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/redact.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/src/scamscript/verify.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/fixtures/reprints/copy-header.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/fixtures/reprints/copy-punctuation.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/fixtures/reprints/near-twin.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/fixtures/reprints/original.txt +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/test_analysis.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/test_client.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/test_dedupe.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/test_ingest.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/test_models.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/test_prompts.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/test_redact.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/test_render.py +0 -0
- {scamscript-0.5.0 → scamscript-0.6.1}/tests/test_verify.py +0 -0
|
@@ -4,12 +4,6 @@ on:
|
|
|
4
4
|
push:
|
|
5
5
|
tags: ["v*"]
|
|
6
6
|
workflow_dispatch:
|
|
7
|
-
inputs:
|
|
8
|
-
repository:
|
|
9
|
-
description: Target index
|
|
10
|
-
type: choice
|
|
11
|
-
options: [pypi, testpypi]
|
|
12
|
-
default: testpypi
|
|
13
7
|
|
|
14
8
|
permissions:
|
|
15
9
|
contents: read
|
|
@@ -38,25 +32,8 @@ jobs:
|
|
|
38
32
|
name: dist
|
|
39
33
|
path: dist/
|
|
40
34
|
|
|
41
|
-
testpypi:
|
|
42
|
-
needs: build
|
|
43
|
-
if: github.event_name == 'workflow_dispatch' && inputs.repository == 'testpypi'
|
|
44
|
-
runs-on: ubuntu-latest
|
|
45
|
-
environment: testpypi
|
|
46
|
-
permissions:
|
|
47
|
-
id-token: write
|
|
48
|
-
steps:
|
|
49
|
-
- uses: actions/download-artifact@v4
|
|
50
|
-
with:
|
|
51
|
-
name: dist
|
|
52
|
-
path: dist/
|
|
53
|
-
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
54
|
-
with:
|
|
55
|
-
repository-url: https://test.pypi.org/legacy/
|
|
56
|
-
|
|
57
35
|
pypi:
|
|
58
36
|
needs: build
|
|
59
|
-
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.repository == 'pypi')
|
|
60
37
|
runs-on: ubuntu-latest
|
|
61
38
|
environment: pypi
|
|
62
39
|
permissions:
|
|
@@ -5,6 +5,27 @@ schema are the things that count as public API.
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.6.1 - 2026-11-02
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Removed the unused TestPyPI job and environment. Manual dispatch publishes to PyPI directly.
|
|
13
|
+
|
|
14
|
+
## 0.6.0 - 2026-11-02
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Closed-set attack type slugs, a per-type count and mean score table, and `--type` filtering with an
|
|
19
|
+
unknown-token warning. Raw descriptions are preserved.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Panels show the slug beneath the attack type.
|
|
24
|
+
|
|
25
|
+
### Security
|
|
26
|
+
|
|
27
|
+
- Log directories are created `0700` and log files `0600`, so captures are not world-readable.
|
|
28
|
+
|
|
8
29
|
## 0.5.0 - 2026-11-02
|
|
9
30
|
|
|
10
31
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scamscript
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Reverse-engineer scam scripts and vishing transcripts into structured counter-playbooks
|
|
5
5
|
Project-URL: Homepage, https://github.com/sjdsjadjajdjsa/scamscript
|
|
6
6
|
Project-URL: Repository, https://github.com/sjdsjadjajdjsa/scamscript
|
|
@@ -134,7 +134,7 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
|
|
|
134
134
|
|
|
135
135
|
Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
|
|
136
136
|
`--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
|
|
137
|
-
`--show-duplicates`, `--no-color`. `--json` puts one object per script on stdout and all
|
|
137
|
+
`--show-duplicates`, `--type`, `--no-color`. `--json` puts one object per script on stdout and all
|
|
138
138
|
Rich output on stderr, so it pipes:
|
|
139
139
|
|
|
140
140
|
```bash
|
|
@@ -155,6 +155,20 @@ The cap reads `usage.cost` from the response, so it works only where the provide
|
|
|
155
155
|
says so when none is sent. Spend is printed and logged per run. In-flight requests can overshoot by
|
|
156
156
|
up to the concurrency, so keep headroom on a hard limit.
|
|
157
157
|
|
|
158
|
+
## Attack types
|
|
159
|
+
|
|
160
|
+
`attack_type` is free prose, which is useful to read and useless to group. Every result also carries a
|
|
161
|
+
closed-set slug (`vishing`, `smishing`, `tech-support`, `refund`, `gov-impersonation`, `bec`,
|
|
162
|
+
`romance`, `crypto`, `investment`, `utility`, `delivery`, `other`). Reports show counts and mean scores
|
|
163
|
+
per slug above the ranking, and `--type` filters to the ones you ask for:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
scamscript batch corpus/ --type tech-support,refund --output report.md
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The raw phrase is always kept next to the slug. An unrecognised description lands in `other` and the
|
|
170
|
+
CLI names any filter token it did not recognise.
|
|
171
|
+
|
|
158
172
|
## Duplicates
|
|
159
173
|
|
|
160
174
|
Corpora are full of reprints. Each script is fingerprinted on its body: headers, casing, punctuation
|
|
@@ -204,7 +218,7 @@ masking is best effort: names, case IDs and spelled-out digits survive it.
|
|
|
204
218
|
Every request appends to a timestamped JSONL file under `~/.scamscript/logs/` (override with
|
|
205
219
|
`--home` or `SCAMSCRIPT_HOME`). The API key is never written. Logs are the source for `report`, so
|
|
206
220
|
a run replays without spending tokens, and `prune --keep 200 --keep-days 30` keeps the store
|
|
207
|
-
bounded.
|
|
221
|
+
bounded. Directories are `0700` and log files `0600`.
|
|
208
222
|
|
|
209
223
|
## Install and configure
|
|
210
224
|
|
|
@@ -277,6 +291,7 @@ docs/ calibration record, sample report, banner, demo
|
|
|
277
291
|
`opening_hook` and `red_flags` against the source before quoting anything.
|
|
278
292
|
- Redaction is regex-based and will miss names and identifiers without a fixed shape.
|
|
279
293
|
- Duplicate folding ignores formatting only; a single changed digit keeps a copy separate.
|
|
294
|
+
- Type slugs are keyword-matched, so an unusual description falls to `other` rather than inventing one.
|
|
280
295
|
- The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
|
|
281
296
|
- Analysis is English-centric: the prompt and schema assume English artifacts.
|
|
282
297
|
- Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
|
|
@@ -98,7 +98,7 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
|
|
|
98
98
|
|
|
99
99
|
Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
|
|
100
100
|
`--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
|
|
101
|
-
`--show-duplicates`, `--no-color`. `--json` puts one object per script on stdout and all
|
|
101
|
+
`--show-duplicates`, `--type`, `--no-color`. `--json` puts one object per script on stdout and all
|
|
102
102
|
Rich output on stderr, so it pipes:
|
|
103
103
|
|
|
104
104
|
```bash
|
|
@@ -119,6 +119,20 @@ The cap reads `usage.cost` from the response, so it works only where the provide
|
|
|
119
119
|
says so when none is sent. Spend is printed and logged per run. In-flight requests can overshoot by
|
|
120
120
|
up to the concurrency, so keep headroom on a hard limit.
|
|
121
121
|
|
|
122
|
+
## Attack types
|
|
123
|
+
|
|
124
|
+
`attack_type` is free prose, which is useful to read and useless to group. Every result also carries a
|
|
125
|
+
closed-set slug (`vishing`, `smishing`, `tech-support`, `refund`, `gov-impersonation`, `bec`,
|
|
126
|
+
`romance`, `crypto`, `investment`, `utility`, `delivery`, `other`). Reports show counts and mean scores
|
|
127
|
+
per slug above the ranking, and `--type` filters to the ones you ask for:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
scamscript batch corpus/ --type tech-support,refund --output report.md
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The raw phrase is always kept next to the slug. An unrecognised description lands in `other` and the
|
|
134
|
+
CLI names any filter token it did not recognise.
|
|
135
|
+
|
|
122
136
|
## Duplicates
|
|
123
137
|
|
|
124
138
|
Corpora are full of reprints. Each script is fingerprinted on its body: headers, casing, punctuation
|
|
@@ -168,7 +182,7 @@ masking is best effort: names, case IDs and spelled-out digits survive it.
|
|
|
168
182
|
Every request appends to a timestamped JSONL file under `~/.scamscript/logs/` (override with
|
|
169
183
|
`--home` or `SCAMSCRIPT_HOME`). The API key is never written. Logs are the source for `report`, so
|
|
170
184
|
a run replays without spending tokens, and `prune --keep 200 --keep-days 30` keeps the store
|
|
171
|
-
bounded.
|
|
185
|
+
bounded. Directories are `0700` and log files `0600`.
|
|
172
186
|
|
|
173
187
|
## Install and configure
|
|
174
188
|
|
|
@@ -241,6 +255,7 @@ docs/ calibration record, sample report, banner, demo
|
|
|
241
255
|
`opening_hook` and `red_flags` against the source before quoting anything.
|
|
242
256
|
- Redaction is regex-based and will miss names and identifiers without a fixed shape.
|
|
243
257
|
- Duplicate folding ignores formatting only; a single changed digit keeps a copy separate.
|
|
258
|
+
- Type slugs are keyword-matched, so an unusual description falls to `other` rather than inventing one.
|
|
244
259
|
- The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
|
|
245
260
|
- Analysis is English-centric: the prompt and schema assume English artifacts.
|
|
246
261
|
- Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scamscript"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.1"
|
|
8
8
|
description = "Reverse-engineer scam scripts and vishing transcripts into structured counter-playbooks"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from scamscript import dedupe, prompts, redact, verify
|
|
10
|
+
from scamscript import dedupe, prompts, redact, taxonomy, verify
|
|
11
11
|
from scamscript.client import ChatClient, ClientError, TruncatedError
|
|
12
12
|
from scamscript.ingest import ScriptFile
|
|
13
13
|
from scamscript.models import BreakdownError, ScriptBreakdown, parse_breakdown
|
|
@@ -28,6 +28,7 @@ class Outcome:
|
|
|
28
28
|
redactions: dict[str, int] | None = None
|
|
29
29
|
skipped: bool = False
|
|
30
30
|
fingerprint: str = ""
|
|
31
|
+
attack_slug: str = ""
|
|
31
32
|
duplicate_of: str | None = None
|
|
32
33
|
issues: list[verify.Issue] | None = None
|
|
33
34
|
verification: dict[str, int] | None = None
|
|
@@ -44,6 +45,7 @@ class Outcome:
|
|
|
44
45
|
"ok": self.ok,
|
|
45
46
|
"skipped": self.skipped,
|
|
46
47
|
"fingerprint": self.fingerprint,
|
|
48
|
+
"attack_slug": self.attack_slug,
|
|
47
49
|
"duplicate_of": self.duplicate_of,
|
|
48
50
|
"attempts": self.attempts,
|
|
49
51
|
"duration_s": round(self.duration, 3),
|
|
@@ -119,6 +121,7 @@ def analyze_script(
|
|
|
119
121
|
try:
|
|
120
122
|
outcome.breakdown = parse_breakdown(visible)
|
|
121
123
|
outcome.error = None
|
|
124
|
+
outcome.attack_slug = taxonomy.slugify(outcome.breakdown.attack_type)
|
|
122
125
|
if verify_quotes:
|
|
123
126
|
outcome.issues = verify.verify_breakdown(outcome.breakdown, text)
|
|
124
127
|
outcome.verification = verify.summarize(outcome.issues)
|
|
@@ -12,7 +12,7 @@ import typer
|
|
|
12
12
|
from rich.console import Console
|
|
13
13
|
from rich.table import Table
|
|
14
14
|
|
|
15
|
-
from scamscript import __version__, prompts, redact, store
|
|
15
|
+
from scamscript import __version__, prompts, redact, store, taxonomy
|
|
16
16
|
from scamscript import report as reporting
|
|
17
17
|
from scamscript.analysis import Outcome, analyze_script
|
|
18
18
|
from scamscript.client import ChatClient
|
|
@@ -234,6 +234,7 @@ def _run(
|
|
|
234
234
|
batch_mode: bool,
|
|
235
235
|
output: Path | None,
|
|
236
236
|
top: int | None,
|
|
237
|
+
types: list[str] | None = None,
|
|
237
238
|
) -> None:
|
|
238
239
|
console = state.console
|
|
239
240
|
settings = state.settings
|
|
@@ -412,6 +413,13 @@ def _run(
|
|
|
412
413
|
cost_usd=round(spent, 6),
|
|
413
414
|
)
|
|
414
415
|
|
|
416
|
+
only_types = taxonomy.parse_filter(types)
|
|
417
|
+
if only_types:
|
|
418
|
+
unknown = taxonomy.unknown(types)
|
|
419
|
+
if unknown:
|
|
420
|
+
console.print(
|
|
421
|
+
f"[yellow]note[/yellow] unknown type(s): {', '.join(sorted(unknown))}"
|
|
422
|
+
)
|
|
415
423
|
collapsed = reporting.collapse(report_entries) if state.dedupe else report_entries
|
|
416
424
|
|
|
417
425
|
if batch_mode and (successes or skipped):
|
|
@@ -430,6 +438,7 @@ def _run(
|
|
|
430
438
|
run_id=logger.run_id,
|
|
431
439
|
command=command,
|
|
432
440
|
model=settings.model,
|
|
441
|
+
only_types=only_types,
|
|
433
442
|
)
|
|
434
443
|
written = _emit_report(console, markdown, output, json_mode=state.json_output)
|
|
435
444
|
if state.json_output:
|
|
@@ -496,6 +505,9 @@ def analyze(
|
|
|
496
505
|
),
|
|
497
506
|
recursive: bool = typer.Option(False, "--recursive", "-r", help="Recurse into subdirectories."),
|
|
498
507
|
top: int | None = typer.Option(None, "--top", min=1, help="Keep only the N most sophisticated scripts."),
|
|
508
|
+
types: list[str] | None = typer.Option(
|
|
509
|
+
None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
|
|
510
|
+
),
|
|
499
511
|
) -> None:
|
|
500
512
|
"""Analyze a single script file, or a directory with --batch."""
|
|
501
513
|
state = _context(ctx)
|
|
@@ -508,7 +520,15 @@ def analyze(
|
|
|
508
520
|
except IngestError as exc:
|
|
509
521
|
_fail(state.console, str(exc))
|
|
510
522
|
return
|
|
511
|
-
_run(
|
|
523
|
+
_run(
|
|
524
|
+
state,
|
|
525
|
+
scripts,
|
|
526
|
+
command="analyze",
|
|
527
|
+
batch_mode=batch_mode,
|
|
528
|
+
output=output,
|
|
529
|
+
top=top,
|
|
530
|
+
types=types,
|
|
531
|
+
)
|
|
512
532
|
|
|
513
533
|
|
|
514
534
|
@app.command()
|
|
@@ -520,6 +540,9 @@ def batch(
|
|
|
520
540
|
),
|
|
521
541
|
recursive: bool = typer.Option(False, "--recursive", "-r", help="Recurse into subdirectories."),
|
|
522
542
|
top: int | None = typer.Option(None, "--top", min=1, help="Keep only the N most sophisticated scripts."),
|
|
543
|
+
types: list[str] | None = typer.Option(
|
|
544
|
+
None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
|
|
545
|
+
),
|
|
523
546
|
) -> None:
|
|
524
547
|
"""Analyze a directory and rank every script by sophistication."""
|
|
525
548
|
state = _context(ctx)
|
|
@@ -528,7 +551,7 @@ def batch(
|
|
|
528
551
|
except IngestError as exc:
|
|
529
552
|
_fail(state.console, str(exc))
|
|
530
553
|
return
|
|
531
|
-
_run(state, scripts, command="batch", batch_mode=True, output=output, top=top)
|
|
554
|
+
_run(state, scripts, command="batch", batch_mode=True, output=output, top=top, types=types)
|
|
532
555
|
|
|
533
556
|
|
|
534
557
|
@app.command()
|
|
@@ -539,6 +562,9 @@ def report( # noqa: A001
|
|
|
539
562
|
),
|
|
540
563
|
output: Path | None = typer.Option(None, "--output", "-o", help="Write the markdown here."),
|
|
541
564
|
logs_dir: Path | None = typer.Option(None, "--logs-dir", help="Override the log directory."),
|
|
565
|
+
types: list[str] | None = typer.Option(
|
|
566
|
+
None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
|
|
567
|
+
),
|
|
542
568
|
) -> None:
|
|
543
569
|
"""Regenerate the aggregated report from a past run's log."""
|
|
544
570
|
state = _context(ctx)
|
|
@@ -565,6 +591,7 @@ def report( # noqa: A001
|
|
|
565
591
|
run_id=str(run["run_id"]),
|
|
566
592
|
command=str(run.get("command") or "report"),
|
|
567
593
|
model=run.get("model"),
|
|
594
|
+
only_types=taxonomy.parse_filter(types),
|
|
568
595
|
)
|
|
569
596
|
_emit_report(state.console, markdown, output)
|
|
570
597
|
|
|
@@ -9,6 +9,7 @@ from rich.table import Table
|
|
|
9
9
|
from rich.text import Text
|
|
10
10
|
|
|
11
11
|
from scamscript.ingest import ScriptFile
|
|
12
|
+
from scamscript import taxonomy
|
|
12
13
|
from scamscript.models import ScriptBreakdown
|
|
13
14
|
from scamscript.verify import Issue, summarize
|
|
14
15
|
|
|
@@ -123,6 +124,7 @@ def render_breakdown(
|
|
|
123
124
|
style=f"bold {border_for(breakdown)}",
|
|
124
125
|
)
|
|
125
126
|
renderables.append(attack)
|
|
127
|
+
renderables.append(Text(f"slug: {taxonomy.slugify(breakdown.attack_type)}", style="dim"))
|
|
126
128
|
renderables.append(Text(""))
|
|
127
129
|
|
|
128
130
|
for key in (
|
|
@@ -7,6 +7,7 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
from pydantic import ValidationError
|
|
9
9
|
|
|
10
|
+
from scamscript import taxonomy
|
|
10
11
|
from scamscript.models import ScriptBreakdown
|
|
11
12
|
from scamscript.store import results_of, utc_now
|
|
12
13
|
|
|
@@ -31,6 +32,7 @@ class ReportEntry:
|
|
|
31
32
|
duration: float = 0.0
|
|
32
33
|
skipped: bool = False
|
|
33
34
|
fingerprint: str = ""
|
|
35
|
+
attack_slug: str = ""
|
|
34
36
|
duplicates: list[str] | None = None
|
|
35
37
|
verification: dict[str, int] | None = None
|
|
36
38
|
unverified: list[dict[str, str]] | None = None
|
|
@@ -47,6 +49,7 @@ def entry_from_outcome(outcome: Any) -> ReportEntry:
|
|
|
47
49
|
breakdown=outcome.breakdown,
|
|
48
50
|
skipped=getattr(outcome, "skipped", False),
|
|
49
51
|
fingerprint=getattr(outcome, "fingerprint", ""),
|
|
52
|
+
attack_slug=getattr(outcome, "attack_slug", ""),
|
|
50
53
|
error=outcome.error,
|
|
51
54
|
duration=getattr(outcome, "duration", 0.0),
|
|
52
55
|
verification=getattr(outcome, "verification", None),
|
|
@@ -75,6 +78,7 @@ def entries_from_run(run: dict[str, Any]) -> list[ReportEntry]:
|
|
|
75
78
|
breakdown=breakdown,
|
|
76
79
|
skipped=bool(record.get("skipped")),
|
|
77
80
|
fingerprint=record.get("fingerprint") or "",
|
|
81
|
+
attack_slug=record.get("attack_slug") or "",
|
|
78
82
|
duplicates=record.get("duplicates") or None,
|
|
79
83
|
error=None if breakdown else record.get("error") or "no breakdown stored",
|
|
80
84
|
duration=float(record.get("duration_s") or 0.0),
|
|
@@ -124,7 +128,14 @@ def build_markdown(
|
|
|
124
128
|
run_id: str,
|
|
125
129
|
command: str = "batch",
|
|
126
130
|
model: str | None = None,
|
|
131
|
+
only_types: set[str] | None = None,
|
|
127
132
|
) -> str:
|
|
133
|
+
if only_types:
|
|
134
|
+
entries = [
|
|
135
|
+
entry
|
|
136
|
+
for entry in entries
|
|
137
|
+
if not entry.ok or (entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)) in only_types
|
|
138
|
+
]
|
|
128
139
|
ranked = rank(entries)
|
|
129
140
|
ok = [entry for entry in ranked if entry.ok]
|
|
130
141
|
skipped = [entry for entry in ranked if entry.skipped and not entry.ok]
|
|
@@ -157,13 +168,21 @@ def build_markdown(
|
|
|
157
168
|
lines.append("")
|
|
158
169
|
|
|
159
170
|
if ok:
|
|
171
|
+
lines.append("## Attack types")
|
|
172
|
+
lines.append("")
|
|
173
|
+
lines.append("| Type | Scripts | Mean score |")
|
|
174
|
+
lines.append("| --- | ---: | ---: |")
|
|
175
|
+
for slug, count, mean in _type_counts(ok):
|
|
176
|
+
lines.append(f"| {taxonomy.label(slug)} (`{slug}`) | {count} | {mean:.1f} |")
|
|
177
|
+
lines.append("")
|
|
160
178
|
lines.append("## Ranking by sophistication")
|
|
161
179
|
lines.append("")
|
|
162
|
-
lines.append("| Rank | Script |
|
|
180
|
+
lines.append("| Rank | Script | Type | Sophistication | Red flags | Unverified | Copies |")
|
|
163
181
|
lines.append("| ---: | --- | --- | ---: | ---: | ---: | ---: |")
|
|
164
182
|
for position, entry in enumerate(ok, start=1):
|
|
165
183
|
lines.append(
|
|
166
|
-
f"| {position} | {_cell(entry.name)} |
|
|
184
|
+
f"| {position} | {_cell(entry.name)} | "
|
|
185
|
+
f"`{entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)}` | "
|
|
167
186
|
f"{entry.breakdown.sophistication_score}/10 | {entry.breakdown.flag_count} | "
|
|
168
187
|
f"{len(entry.unverified or [])} | {1 + len(entry.duplicates or [])} |"
|
|
169
188
|
)
|
|
@@ -203,6 +222,9 @@ def _detail_section(position: int, entry: ReportEntry) -> list[str]:
|
|
|
203
222
|
assert breakdown is not None
|
|
204
223
|
lines = [f"## {position}. {entry.name}", ""]
|
|
205
224
|
lines.append(f"- **Attack type:** {breakdown.attack_type}")
|
|
225
|
+
lines.append(
|
|
226
|
+
f"- **Type slug:** `{entry.attack_slug or taxonomy.slugify(breakdown.attack_type)}`"
|
|
227
|
+
)
|
|
206
228
|
lines.append(f"- **Sophistication:** {breakdown.sophistication_score}/10")
|
|
207
229
|
lines.append(f"- **Red flags observed:** {breakdown.flag_count}")
|
|
208
230
|
if entry.duplicates:
|
|
@@ -238,5 +260,19 @@ def _detail_section(position: int, entry: ReportEntry) -> list[str]:
|
|
|
238
260
|
return lines
|
|
239
261
|
|
|
240
262
|
|
|
263
|
+
def _type_counts(entries: list[ReportEntry]) -> list[tuple[str, int, float]]:
|
|
264
|
+
buckets: dict[str, list[int]] = {}
|
|
265
|
+
for entry in entries:
|
|
266
|
+
if not entry.breakdown:
|
|
267
|
+
continue
|
|
268
|
+
slug = entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)
|
|
269
|
+
buckets.setdefault(slug, []).append(entry.breakdown.sophistication_score)
|
|
270
|
+
rows = [
|
|
271
|
+
(slug, len(scores), sum(scores) / len(scores))
|
|
272
|
+
for slug, scores in buckets.items()
|
|
273
|
+
]
|
|
274
|
+
return sorted(rows, key=lambda row: (-row[1], row[0]))
|
|
275
|
+
|
|
276
|
+
|
|
241
277
|
def _cell(value: str) -> str:
|
|
242
278
|
return (value or "").replace("|", "\\|").replace("\n", " ").strip()
|
|
@@ -31,14 +31,23 @@ class RunLogger:
|
|
|
31
31
|
self.command = command
|
|
32
32
|
self.path = self.logs_dir / f"{self.run_id}.jsonl"
|
|
33
33
|
self._started = False
|
|
34
|
+
self._locked_down = False
|
|
35
|
+
|
|
36
|
+
def _prepare(self) -> None:
|
|
37
|
+
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
for directory in (self.logs_dir.parent, self.logs_dir):
|
|
39
|
+
_chmod(directory, 0o700)
|
|
34
40
|
|
|
35
41
|
def _write(self, record: dict[str, Any]) -> None:
|
|
36
42
|
record.setdefault("ts", utc_now())
|
|
37
43
|
record.setdefault("run_id", self.run_id)
|
|
38
44
|
try:
|
|
39
|
-
self.
|
|
45
|
+
self._prepare()
|
|
40
46
|
with self.path.open("a", encoding="utf-8") as handle:
|
|
41
47
|
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
48
|
+
if not self._locked_down:
|
|
49
|
+
self._locked_down = True
|
|
50
|
+
_chmod(self.path, 0o600)
|
|
42
51
|
except OSError:
|
|
43
52
|
pass
|
|
44
53
|
|
|
@@ -145,9 +154,18 @@ def _mtime(path: Path) -> float:
|
|
|
145
154
|
return 0.0
|
|
146
155
|
|
|
147
156
|
|
|
157
|
+
def _chmod(path: Path, mode: int) -> None:
|
|
158
|
+
try:
|
|
159
|
+
os.chmod(path, mode)
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
148
164
|
def ensure_home(home: str) -> None:
|
|
149
165
|
try:
|
|
150
166
|
os.makedirs(os.path.join(home, "logs"), exist_ok=True)
|
|
167
|
+
_chmod(Path(home), 0o700)
|
|
168
|
+
_chmod(Path(home) / "logs", 0o700)
|
|
151
169
|
except OSError:
|
|
152
170
|
pass
|
|
153
171
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Map free-text attack descriptions onto a fixed set of slugs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
OTHER = "other"
|
|
8
|
+
|
|
9
|
+
# Checked in order: the earlier, more specific family wins.
|
|
10
|
+
RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
11
|
+
("bec", ("business email", "bec", "invoice fraud", "vendor payment", "mandate fraud", "ceo fraud")),
|
|
12
|
+
("tech-support", ("tech support", "tech-support", "remote access", "windows security", "computer support")),
|
|
13
|
+
("refund", ("refund", "overpayment", "rebate")),
|
|
14
|
+
("gov-impersonation", ("government", "tax", "revenue", "irs", "hmrc", "dvla", "council", "police", "court")),
|
|
15
|
+
("utility", ("utility", "water board", "energy", "electric", "gas board", "boiler", "home service")),
|
|
16
|
+
("delivery", ("parcel", "delivery", "courier", "postage", "shipping")),
|
|
17
|
+
("crypto", ("crypto", "bitcoin", "wallet", "token")),
|
|
18
|
+
("investment", ("investment", "trading", "broker", "forex", "pig butchering")),
|
|
19
|
+
("romance", ("romance", "dating", "long con", "long-con", "sweetheart")),
|
|
20
|
+
("smishing", ("smishing", "sms", "text message", "text-message", "whatsapp")),
|
|
21
|
+
("vishing", ("vishing", "phone call", "cold call", "robocall", "phone")),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
SLUGS = tuple(slug for slug, _ in RULES) + (OTHER,)
|
|
25
|
+
NON_WORD = re.compile(r"[^a-z0-9]+")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def slugify(attack_type: str) -> str:
|
|
29
|
+
"""Return a canonical slug for a model-written attack description."""
|
|
30
|
+
text = NON_WORD.sub(" ", (attack_type or "").lower())
|
|
31
|
+
for slug, needles in RULES:
|
|
32
|
+
if any(needle in text for needle in needles):
|
|
33
|
+
return slug
|
|
34
|
+
return OTHER
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def unknown(values: list[str] | None) -> set[str]:
|
|
38
|
+
"""Raw filter tokens that are not a known slug."""
|
|
39
|
+
if not values:
|
|
40
|
+
return set()
|
|
41
|
+
out: set[str] = set()
|
|
42
|
+
for value in values:
|
|
43
|
+
for chunk in value.split(","):
|
|
44
|
+
token = chunk.strip().lower()
|
|
45
|
+
if token and token not in SLUGS:
|
|
46
|
+
out.add(token)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def label(slug: str) -> str:
|
|
51
|
+
return {
|
|
52
|
+
"bec": "Business email compromise",
|
|
53
|
+
"tech-support": "Tech support",
|
|
54
|
+
"refund": "Refund",
|
|
55
|
+
"gov-impersonation": "Government impersonation",
|
|
56
|
+
"utility": "Utility",
|
|
57
|
+
"delivery": "Delivery",
|
|
58
|
+
"crypto": "Crypto",
|
|
59
|
+
"investment": "Investment",
|
|
60
|
+
"romance": "Romance",
|
|
61
|
+
"smishing": "Smishing",
|
|
62
|
+
"vishing": "Vishing",
|
|
63
|
+
"other": "Other",
|
|
64
|
+
}.get(slug, slug)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_filter(values: list[str] | None) -> set[str]:
|
|
68
|
+
"""Turn a comma-separated --type option into a slug set."""
|
|
69
|
+
if not values:
|
|
70
|
+
return set()
|
|
71
|
+
out: set[str] = set()
|
|
72
|
+
for value in values:
|
|
73
|
+
for chunk in value.split(","):
|
|
74
|
+
slug = chunk.strip().lower()
|
|
75
|
+
if slug:
|
|
76
|
+
out.add(slugify(slug))
|
|
77
|
+
return out
|
|
@@ -230,7 +230,7 @@ def test_dry_run_without_endpoint(home, tmp_path, monkeypatch):
|
|
|
230
230
|
def test_version(home):
|
|
231
231
|
result = runner.invoke(cli.app, ["--version"])
|
|
232
232
|
assert result.exit_code == 0
|
|
233
|
-
assert result.stdout.strip() == "scamscript 0.
|
|
233
|
+
assert result.stdout.strip() == "scamscript 0.6.1"
|
|
234
234
|
|
|
235
235
|
|
|
236
236
|
def test_json_output_is_one_object_per_script(home, tmp_path, fake):
|
|
@@ -455,3 +455,26 @@ def test_json_marks_duplicates(home, tmp_path, fake):
|
|
|
455
455
|
results = [record for record in records if record.get("event") == "result"]
|
|
456
456
|
assert all(record["fingerprint"] for record in results)
|
|
457
457
|
assert sum(1 for record in results if record["duplicate_of"]) == 2
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_type_filter_limits_report(home, tmp_path, fake):
|
|
461
|
+
(tmp_path / "a.txt").write_text("CALLER: vish", encoding="utf-8")
|
|
462
|
+
(tmp_path / "b.txt").write_text("CALLER: romance", encoding="utf-8")
|
|
463
|
+
fake.replies = {"vish": breakdown(attack="vishing"), "romance": breakdown(attack="romance scam")}
|
|
464
|
+
out = tmp_path / "r.md"
|
|
465
|
+
result = runner.invoke(
|
|
466
|
+
cli.app, ["--api-key", "k", "batch", str(tmp_path), "--type", "vishing", "--output", str(out)]
|
|
467
|
+
)
|
|
468
|
+
assert result.exit_code == 0
|
|
469
|
+
text = out.read_text()
|
|
470
|
+
assert "a.txt" in text
|
|
471
|
+
assert "b.txt" not in text
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def test_unknown_type_warns(home, tmp_path, fake):
|
|
475
|
+
(tmp_path / "a.txt").write_text("CALLER: x", encoding="utf-8")
|
|
476
|
+
result = runner.invoke(
|
|
477
|
+
cli.app, ["--api-key", "k", "batch", str(tmp_path), "--type", "wibble"]
|
|
478
|
+
)
|
|
479
|
+
assert result.exit_code == 0
|
|
480
|
+
assert "unknown type" in result.stdout
|
|
@@ -31,10 +31,11 @@ def test_markdown_has_summary_and_details():
|
|
|
31
31
|
]
|
|
32
32
|
markdown = build_markdown(entries, run_id="run-1", command="batch", model="m")
|
|
33
33
|
assert "# Scam script breakdown report" in markdown
|
|
34
|
-
assert "| 1 | a.txt | vishing | 9/10 | 1 |" in markdown
|
|
34
|
+
assert "| 1 | a.txt | `vishing` | 9/10 | 1 | 0 | 1 |" in markdown
|
|
35
35
|
assert "## Failures" in markdown
|
|
36
36
|
assert "bad json" in markdown
|
|
37
37
|
assert "### Defense summary" in markdown
|
|
38
|
+
assert "## Attack types" in markdown
|
|
38
39
|
assert "security research and fraud-awareness" in markdown
|
|
39
40
|
|
|
40
41
|
|
|
@@ -77,3 +78,15 @@ def test_report_shows_unverified_quotes():
|
|
|
77
78
|
assert "**Quote check:** 1/2 verbatim, 1 missing" in markdown
|
|
78
79
|
assert "### Unverified quotes" in markdown
|
|
79
80
|
assert "_missing_ [red flags] \"invented\"" in markdown
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_type_filter_keeps_only_allowed_and_failures():
|
|
84
|
+
entries = [
|
|
85
|
+
ReportEntry(name="a.txt", source="a", breakdown=breakdown(9, attack="vishing"), attack_slug="vishing"),
|
|
86
|
+
ReportEntry(name="b.txt", source="b", breakdown=breakdown(5, attack="romance scam"), attack_slug="romance"),
|
|
87
|
+
ReportEntry(name="bad.txt", source="bad", error="boom"),
|
|
88
|
+
]
|
|
89
|
+
markdown = build_markdown(entries, run_id="r", only_types={"vishing"})
|
|
90
|
+
assert "a.txt" in markdown
|
|
91
|
+
assert "b.txt" not in markdown
|
|
92
|
+
assert "bad.txt" in markdown
|
|
@@ -38,3 +38,13 @@ def test_prune_by_age(tmp_path):
|
|
|
38
38
|
|
|
39
39
|
def test_prune_missing_dir(tmp_path):
|
|
40
40
|
assert prune(str(tmp_path / "ghost")) == (0, 0, 0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_log_dir_and_file_are_private(tmp_path):
|
|
44
|
+
from scamscript.store import RunLogger
|
|
45
|
+
|
|
46
|
+
log = RunLogger(str(tmp_path / "home" / "logs"), "analyze")
|
|
47
|
+
log.run_start(model="m")
|
|
48
|
+
assert oct((tmp_path / "home").stat().st_mode)[-3:] == "700"
|
|
49
|
+
assert oct((tmp_path / "home" / "logs").stat().st_mode)[-3:] == "700"
|
|
50
|
+
assert oct(log.path.stat().st_mode)[-3:] == "600"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from scamscript.taxonomy import SLUGS, label, parse_filter, slugify
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.parametrize(
|
|
7
|
+
"text,slug",
|
|
8
|
+
[
|
|
9
|
+
("government impersonation vishing (tax/revenue)", "gov-impersonation"),
|
|
10
|
+
("vishing (automated robocall payment scam)", "vishing"),
|
|
11
|
+
("romance scam / smishing long con", "romance"),
|
|
12
|
+
("parcel delivery fee scam", "delivery"),
|
|
13
|
+
("tech support scam with remote access", "tech-support"),
|
|
14
|
+
("business email compromise by phone", "bec"),
|
|
15
|
+
("crypto investment", "crypto"),
|
|
16
|
+
("pig butchering investment scam", "investment"),
|
|
17
|
+
("smishing text message", "smishing"),
|
|
18
|
+
("utility impersonation", "utility"),
|
|
19
|
+
("something entirely new", "other"),
|
|
20
|
+
("", "other"),
|
|
21
|
+
],
|
|
22
|
+
)
|
|
23
|
+
def test_mapping(text, slug):
|
|
24
|
+
assert slugify(text) == slug
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_bec_wins_over_vishing():
|
|
28
|
+
assert slugify("vishing-led wire fraud, business email compromise") == "bec"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_case_and_punctuation_insensitive():
|
|
32
|
+
assert slugify("TECH-SUPPORT, remote access!") == "tech-support"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_slugs_are_closed_set():
|
|
36
|
+
assert "other" in SLUGS and "vishing" in SLUGS
|
|
37
|
+
assert slugify("nonsense") in SLUGS
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_parse_filter_accepts_comma_and_spaces():
|
|
41
|
+
assert parse_filter(["tech support,romance", "vishing"]) == {
|
|
42
|
+
"tech-support",
|
|
43
|
+
"romance",
|
|
44
|
+
"vishing",
|
|
45
|
+
}
|
|
46
|
+
assert parse_filter(None) == set()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_label_known_and_fallback():
|
|
50
|
+
assert label("bec") == "Business email compromise"
|
|
51
|
+
assert label("odd") == "odd"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|