penthera 1.5.0 → 1.6.1
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.
- package/CHANGELOG.md +179 -0
- package/README.md +128 -663
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +9 -0
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +4 -3
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +290 -30
- package/lib/deps/lockfile.js +25 -2
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +37 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +41 -8
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +17 -5
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/secrets.js +20 -7
- package/package.json +7 -3
- package/skills/penthera/SKILL.md +23 -5
- package/src/cli/deps.js +60 -5
- package/src/cli/evidence.js +14 -4
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +19 -7
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# The AI-tooling supply chain: for teams adopting Claude Code or Cursor
|
|
2
|
+
|
|
3
|
+
## The problem
|
|
4
|
+
|
|
5
|
+
A coding agent runs with your developer's permissions. What it can do is decided
|
|
6
|
+
by files that rarely get reviewed: `.mcp.json`, `.claude/settings.json`, hooks,
|
|
7
|
+
skills, plugins, `CLAUDE.md`, `AGENTS.md` and `.cursor/` rules. Some of these
|
|
8
|
+
arrive from a teammate's pull request, some from a marketplace, some from a
|
|
9
|
+
blog post. In 2025 and 2026 attackers used exactly these paths: skills with
|
|
10
|
+
instructions hidden from human reviewers, MCP servers launched with `npx -y` on
|
|
11
|
+
whatever version was latest, and npm worms that wrote to `~/.claude` and ran the
|
|
12
|
+
`claude` CLI on the victim's machine.
|
|
13
|
+
|
|
14
|
+
## What to run
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx penthera agents --home # this repo plus your user-level Claude Code, Cursor and Claude Desktop config
|
|
18
|
+
npx penthera agents ./some-skill # vet a downloaded skill before you install it
|
|
19
|
+
npx penthera agents --pin # record hashes in penthera.lock; later runs show what changed
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
For a team, add the pre-commit hook so agent-config changes are checked before
|
|
23
|
+
they are committed, and the GitHub Action in `agents` mode on pull requests:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx penthera hook install
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
- uses: danoszz/penthera@<release-commit-sha>
|
|
31
|
+
with:
|
|
32
|
+
mode: agents
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What you get
|
|
36
|
+
|
|
37
|
+
From Penthera's own test fixture of a deliberately bad setup (abridged):
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
Grade F (0/100)
|
|
41
|
+
Found 1 skills · 4 MCP servers · 3 hooks · 0 plugins · 1 rule files
|
|
42
|
+
Lock none (run with --pin)
|
|
43
|
+
|
|
44
|
+
CRIT MCP server "installer" launches by downloading and running code
|
|
45
|
+
.mcp.json
|
|
46
|
+
CRIT SessionStart hook downloads and runs code
|
|
47
|
+
.claude/settings.json
|
|
48
|
+
HIGH Instructions hidden in an HTML comment
|
|
49
|
+
skills/helper/SKILL.md
|
|
50
|
+
HIGH Text addressed to a security scanner
|
|
51
|
+
skills/helper/SKILL.md
|
|
52
|
+
HIGH PreToolUse (*) hook auto-approves tool calls
|
|
53
|
+
.claude/settings.json
|
|
54
|
+
HIGH Permission allow-list grants `Bash(*)`
|
|
55
|
+
.claude/settings.json
|
|
56
|
+
MED MCP server "filesystem" runs an unpinned package via npx
|
|
57
|
+
.mcp.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Each finding names the OWASP MCP Top 10 and Agentic Top 10 ids it maps to, and
|
|
61
|
+
the rule that fired is readable in [`lib/agents/`](../../lib/agents/). The audit
|
|
62
|
+
is offline and read-only: no model judges the skill, so a skill cannot talk its
|
|
63
|
+
way past it. [More on `penthera agents`](../commands/agents.md).
|
|
64
|
+
|
|
65
|
+
`penthera deps` also flags packages that write to `~/.claude`, `~/.cursor` or
|
|
66
|
+
`.mcp.json`, or run the `claude`, `gemini` or `q` CLIs, which is how the
|
|
67
|
+
s1ngularity attack used local AI tools. [More on `penthera deps`](../commands/deps.md).
|
|
68
|
+
|
|
69
|
+
## What it does not do
|
|
70
|
+
|
|
71
|
+
- It reads configuration and files on disk. It does not watch what an agent does
|
|
72
|
+
at run time, and it does not sandbox anything.
|
|
73
|
+
- It does not know whether a remote MCP server is trustworthy. It checks how the
|
|
74
|
+
server is launched and configured, not what the service behind it does.
|
|
75
|
+
- It does not check the registries for newly published malicious skills or
|
|
76
|
+
packages. A registry-watching service such as
|
|
77
|
+
[Socket](../compare/socket.md) covers that.
|
|
78
|
+
- Its rules cover the patterns in the [methodology](../METHODOLOGY.md). A new
|
|
79
|
+
trick needs a new rule and a release.
|
|
80
|
+
|
|
81
|
+
[All use cases](README.md) · [Back to the README](../../README.md)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Evidence for NIS2 and the CRA: for EU SMEs
|
|
2
|
+
|
|
3
|
+
## The problem
|
|
4
|
+
|
|
5
|
+
NIS2 puts a duty of care on in-scope organisations, written into national law
|
|
6
|
+
(the Cyberbeveiligingswet in the Netherlands, the NIS2-wet in Belgium). The
|
|
7
|
+
Cyber Resilience Act adds reporting duties for manufacturers of products with
|
|
8
|
+
digital elements, with deadlines measured in hours. Large customers now send
|
|
9
|
+
security questionnaires as part of procurement. A small company has to show
|
|
10
|
+
that it tests, fixes and records, without a compliance team or a budget for a
|
|
11
|
+
platform.
|
|
12
|
+
|
|
13
|
+
## What to run
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
penthera --assessment-init assessment.json # once: the organisational measures a scanner cannot test
|
|
17
|
+
penthera https://staging.example.com --repo . \
|
|
18
|
+
--framework nis2 --readiness --assessment assessment.json \
|
|
19
|
+
--sbom sbom.cdx.json --evidence evidence/2026-10-19 --sign-key ~/.ssh/id_ed25519
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That run writes the scan, an Article 21(2) coverage table, a readiness report
|
|
23
|
+
with every measure marked met, partial, gap or not applicable, a CycloneDX SBOM,
|
|
24
|
+
and a hashed, SSH-signed evidence bundle an auditor can verify with standard
|
|
25
|
+
tools.
|
|
26
|
+
|
|
27
|
+
Around it:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
penthera --policy-pack ./policies --org "Your Company" --readiness --assessment assessment.json
|
|
31
|
+
penthera https://example.com --readiness --assessment assessment.json --questionnaire questions.txt
|
|
32
|
+
penthera --cra-init cra.json && penthera --cra cra.json # CRA Article 14 timeline and drafts
|
|
33
|
+
penthera --repo . --aibom aibom.cdx.json # CycloneDX 1.6 AI-BOM
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## What you get
|
|
37
|
+
|
|
38
|
+
The CRA helper prints the deadlines and writes draft reports, marking every fact
|
|
39
|
+
it still needs:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
CRA Article 14 report drafts written to cra-reports.md
|
|
43
|
+
Early warning (24 hours): n/a, set aware_at
|
|
44
|
+
Vulnerability notification (72 hours): n/a, set aware_at
|
|
45
|
+
Final report (14 days after a corrective or mitigating measure is available): ...
|
|
46
|
+
⚠️ No valid `aware_at` timestamp, so deadlines can't be computed. Set when you became aware (ISO 8601) and re-run.
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The questionnaire helper answers each question from your scan and
|
|
50
|
+
self-assessment, cites the evidence, and marks anything it cannot map as
|
|
51
|
+
`NEEDS HUMAN INPUT` instead of guessing. Guides:
|
|
52
|
+
[NIS2](../compliance-nis2.md), [CRA](../compliance-cra.md),
|
|
53
|
+
[evidence bundles](../evidence.md).
|
|
54
|
+
|
|
55
|
+
## What it does not do
|
|
56
|
+
|
|
57
|
+
- It is readiness tooling, not a certification and not legal advice. Whether you
|
|
58
|
+
are in scope, and what is proportionate for you, is for you and your advisers
|
|
59
|
+
to decide.
|
|
60
|
+
- It covers the technical, testable part of the duty of care. Policies,
|
|
61
|
+
training, backups and MFA come from your self-assessment; Penthera records
|
|
62
|
+
what you attest, it cannot check it.
|
|
63
|
+
- It does not file anything. Reports to a CSIRT or ENISA are drafts for you to
|
|
64
|
+
review and submit.
|
|
65
|
+
- Jurisdiction details ship for the Netherlands today; other member states are
|
|
66
|
+
configuration still to be added.
|
|
67
|
+
|
|
68
|
+
[All use cases](README.md) · [Back to the README](../../README.md)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# For open-source maintainers
|
|
2
|
+
|
|
3
|
+
## The problem
|
|
4
|
+
|
|
5
|
+
Your project is someone else's dependency. A compromised maintainer account, a
|
|
6
|
+
malicious contributor pull request that bumps a lockfile, or a leaked publish
|
|
7
|
+
token can turn it into the attack. Most maintainers do this unpaid, without a
|
|
8
|
+
security budget, and many cannot send their dependency list or code to a vendor
|
|
9
|
+
service, or would rather not sign up for one.
|
|
10
|
+
|
|
11
|
+
## What to run
|
|
12
|
+
|
|
13
|
+
On every pull request that touches the lockfile, comment what the change lets
|
|
14
|
+
code do:
|
|
15
|
+
|
|
16
|
+
```yaml
|
|
17
|
+
- uses: danoszz/penthera@<release-commit-sha> # pin to a release commit
|
|
18
|
+
with:
|
|
19
|
+
mode: deps
|
|
20
|
+
fail-on: none
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The full workflow, with permissions and a paths filter, is in
|
|
24
|
+
[`docs/examples/pr-dependency-review.yml`](../examples/pr-dependency-review.yml).
|
|
25
|
+
The action runs Penthera from its own checkout: no install from npm and no
|
|
26
|
+
registry lookups. On pull requests from forks the token is read-only, so the
|
|
27
|
+
diff goes to the job summary instead of a comment.
|
|
28
|
+
|
|
29
|
+
Locally, before you cut a release:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx penthera deps --base origin/main # what changed in the tree since main
|
|
33
|
+
npx penthera --repo . # secrets, including AI provider keys
|
|
34
|
+
npx penthera agents # the agent config you ship (CLAUDE.md, .mcp.json, skills/)
|
|
35
|
+
npx penthera hook install # block secrets and risky config at commit time
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## What you get
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
▲ net-helper 1.0.0 → 1.1.0 + network, whole environment
|
|
42
|
+
node_modules/net-helper/index.js:3 const https = require('https');
|
|
43
|
+
node_modules/net-helper/index.js:5 const body = JSON.stringify(process.env);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A patch release that starts reading the whole environment next to a network call
|
|
47
|
+
is the shape of most credential-stealing packages. The diff shows it with
|
|
48
|
+
`file:line` evidence, whether or not an advisory exists yet.
|
|
49
|
+
[More on `penthera deps`](../commands/deps.md) and [the action](../commands/action.md).
|
|
50
|
+
|
|
51
|
+
If your project ships agent skills or MCP configuration, `penthera agents` on
|
|
52
|
+
your own repo is a reasonable release check too: it is what your users' tools
|
|
53
|
+
will load.
|
|
54
|
+
|
|
55
|
+
## How Penthera handles its own releases
|
|
56
|
+
|
|
57
|
+
The same practices are worth copying. Penthera releases are published with npm
|
|
58
|
+
provenance through trusted publishing and are staged: each version waits on npm
|
|
59
|
+
until a maintainer approves it with 2FA. CI actions are pinned by SHA, CodeQL
|
|
60
|
+
runs on every push, and the
|
|
61
|
+
[OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/danoszz/penthera)
|
|
62
|
+
is public. See [`.github/workflows/publish.yml`](../../.github/workflows/publish.yml).
|
|
63
|
+
|
|
64
|
+
## What it does not do
|
|
65
|
+
|
|
66
|
+
- Dependency analysis covers npm and Python. The install firewall and the
|
|
67
|
+
`--online` registry checks are npm only for now.
|
|
68
|
+
- It does not track CVEs in your dependencies. Keep Dependabot or a similar
|
|
69
|
+
service on for that ([how they fit](../compare/github.md)).
|
|
70
|
+
- It does not watch the registry for your package being republished by someone
|
|
71
|
+
else. Enable 2FA and trusted publishing on npm for that.
|
|
72
|
+
- A capability is what code *can* do, not proof of intent. Many good packages
|
|
73
|
+
use the network; the diff is there so a person can look.
|
|
74
|
+
|
|
75
|
+
[All use cases](README.md) · [Back to the README](../../README.md)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Before launch: for solo founders and vibecoders
|
|
2
|
+
|
|
3
|
+
## The problem
|
|
4
|
+
|
|
5
|
+
You built the app in weeks, much of it with an AI assistant. It works. You have
|
|
6
|
+
not had time to check the things that tend to go wrong in AI-written apps: an
|
|
7
|
+
API route that returns any user's data if you change the id, an OpenAI key in a
|
|
8
|
+
client bundle, a missing security header, a CORS policy of `*`, a login with no
|
|
9
|
+
rate limit. Nobody on the team does security, because there is no team.
|
|
10
|
+
|
|
11
|
+
## What to run
|
|
12
|
+
|
|
13
|
+
Start the app locally, then:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx penthera http://localhost:3000 --repo . # the running app and its source, together
|
|
17
|
+
npx penthera fix --plan --repo . # fixes for what it can fix, as diffs
|
|
18
|
+
npx penthera hook install # stop secrets at commit time from now on
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or ask your coding agent, with the [Agent Skill](../commands/skill.md)
|
|
22
|
+
installed: "scan my app on localhost:3000 and fix what you find".
|
|
23
|
+
|
|
24
|
+
Before you deploy, run the same scan against your preview URL. Only scan systems
|
|
25
|
+
you own.
|
|
26
|
+
|
|
27
|
+
## What you get
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Findings
|
|
31
|
+
|
|
32
|
+
HIGH IDOR/BOLA: GET /api/users/{user_id}
|
|
33
|
+
http://localhost:3000/api/users/1 → 200
|
|
34
|
+
HIGH OAuth open redirect
|
|
35
|
+
http://localhost:3000/api/oauth/callback?redirect_uri=... → 302
|
|
36
|
+
MED Missing Content-Security-Policy
|
|
37
|
+
MED No login rate limiting detected
|
|
38
|
+
http://localhost:3000/api/login → 401
|
|
39
|
+
LOW Missing X-Frame-Options
|
|
40
|
+
INFO Missing Referrer-Policy
|
|
41
|
+
|
|
42
|
+
──────────────────────────────────────────────────────────
|
|
43
|
+
2 high · 5 medium · 2 low · 2 info
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`penthera fix --plan` then prints a unified diff per fix it knows (security
|
|
47
|
+
headers, cookie flags, a CORS allowlist, a login rate limit, moving a
|
|
48
|
+
hardcoded secret to an environment variable) for Next.js, Express, Fastify and
|
|
49
|
+
Hono. Nothing is written until you apply a plan by id, and the repo is
|
|
50
|
+
re-scanned afterwards to confirm the finding is gone.
|
|
51
|
+
[More on fixes](../commands/fix.md).
|
|
52
|
+
|
|
53
|
+
## What it does not do
|
|
54
|
+
|
|
55
|
+
- It is not a penetration test by a person. It checks known classes of problem;
|
|
56
|
+
it will not find a flaw in your business logic, such as a coupon that can be
|
|
57
|
+
applied twice.
|
|
58
|
+
- It does not fix IDOR or auth design for you. It points at the route; the fix
|
|
59
|
+
is yours.
|
|
60
|
+
- It does not watch production yet. Scheduled checks are on the
|
|
61
|
+
[roadmap](../../ROADMAP.md).
|
|
62
|
+
- A clean scan is not proof of safety. Every finding has a confidence label, and
|
|
63
|
+
probes that could not run are listed as inconclusive rather than passed.
|
|
64
|
+
|
|
65
|
+
[All use cases](README.md) · [Back to the README](../../README.md)
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* live DNS.
|
|
14
14
|
*/
|
|
15
15
|
import { resolveTxt as dnsResolveTxt, resolveMx as dnsResolveMx } from "node:dns/promises";
|
|
16
|
+
import { currentEgress } from "../../src/utils/http.js";
|
|
16
17
|
|
|
17
18
|
const DEFAULT_RESOLVER = { resolveTxt: dnsResolveTxt, resolveMx: dnsResolveMx };
|
|
18
19
|
|
|
@@ -98,6 +99,10 @@ export function evaluateDmarc(txtRecords) {
|
|
|
98
99
|
* @param {object} opts - { resolver }
|
|
99
100
|
*/
|
|
100
101
|
export async function probeEmailDns(domain, opts = {}) {
|
|
102
|
+
// DNS queries go to resolvers, not the target: --offline forbids them.
|
|
103
|
+
if (currentEgress()?.offline) {
|
|
104
|
+
return { domain, spf: null, dmarc: null, dmarcPolicy: null, mxCount: 0, findings: [], skipped: "offline" };
|
|
105
|
+
}
|
|
101
106
|
const resolver = opts.resolver || DEFAULT_RESOLVER;
|
|
102
107
|
const findings = [];
|
|
103
108
|
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* not a certification, and the owner must review every answer before sending.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { mdInline } from "../../src/report/md-escape.js";
|
|
14
|
+
|
|
13
15
|
// Keyword -> NIS2 measure. First match wins; order specific before general.
|
|
14
16
|
const RULES = [
|
|
15
17
|
{ re: /\b(mfa|multi.?factor|two.?factor|2fa)\b/i, measure: "j" },
|
|
@@ -99,14 +101,14 @@ export function formatQuestionnaireMarkdown(result) {
|
|
|
99
101
|
const basis = a.measure
|
|
100
102
|
? `(${a.measure}) ${a.status || "?"}${a.evidence?.length ? `: ${a.evidence.slice(0, 3).join(", ")}` : ""}`
|
|
101
103
|
: "unmapped";
|
|
102
|
-
L.push(`| ${a.question
|
|
104
|
+
L.push(`| ${mdInline(a.question)} | ${mdInline(a.answer)} | ${mdInline(basis)} |`);
|
|
103
105
|
}
|
|
104
106
|
L.push("");
|
|
105
107
|
const gaps = result.answers.filter((a) => a.answer === "NEEDS HUMAN INPUT").map((a) => a.question);
|
|
106
108
|
if (gaps.length) {
|
|
107
109
|
L.push("## Needs human input");
|
|
108
110
|
L.push("");
|
|
109
|
-
gaps.forEach((q) => L.push(`- ${q}`));
|
|
111
|
+
gaps.forEach((q) => L.push(`- ${mdInline(q)}`));
|
|
110
112
|
L.push("");
|
|
111
113
|
}
|
|
112
114
|
return L.join("\n");
|
package/lib/deps/capabilities.js
CHANGED
|
@@ -48,8 +48,30 @@ export const CAPABILITIES = {
|
|
|
48
48
|
why: "Runs an AI coding CLI (claude, gemini, q), the pattern used by the s1ngularity attack to search the disk for secrets." },
|
|
49
49
|
"token-harvest": { label: "GitHub token harvesting", severity: "critical", confidence: "likely",
|
|
50
50
|
why: "Reads GitHub credentials: runs the gh CLI to print its token, reads gh's hosts file or git's credential store, or matches token-shaped strings next to network code." },
|
|
51
|
+
// Python only (lib/deps/python): code that runs whenever the interpreter starts, and unpickling.
|
|
52
|
+
"startup-hook": { label: "runs at Python startup", severity: "high", confidence: "confirmed",
|
|
53
|
+
why: "Ships a .pth file with an import line, or writes one. Python runs those lines on every interpreter start in that environment, a known persistence trick." },
|
|
54
|
+
deserialize: { label: "unpickles data", severity: "low", confidence: "likely",
|
|
55
|
+
why: "Loads pickle, marshal or unsafe YAML data, which runs code if the data is attacker-controlled." },
|
|
51
56
|
};
|
|
52
57
|
|
|
58
|
+
/** Python wording for capabilities whose npm description names npm or Node APIs. */
|
|
59
|
+
const PY_WHY = {
|
|
60
|
+
"install-scripts": "Runs code at install time: a setup.py with custom commands, or an in-tree build backend, executes when pip builds the package from source.",
|
|
61
|
+
shell: "Can run arbitrary programs (subprocess, os.system, os.popen, pty).",
|
|
62
|
+
network: "Can open network connections (socket, urllib, requests, httpx, aiohttp and similar).",
|
|
63
|
+
env: "Reads os.environ.",
|
|
64
|
+
"env-bulk": "Serialises or enumerates all of os.environ, which holds tokens and keys.",
|
|
65
|
+
eval: "Builds code at runtime with eval, exec or compile.",
|
|
66
|
+
"dynamic-require": "Imports modules whose names are computed at runtime (__import__, importlib).",
|
|
67
|
+
native: "Ships native extensions (.so, .pyd) or loads libraries with ctypes, which this scanner cannot read.",
|
|
68
|
+
obfuscated: "Code looks deliberately hidden (encoded blobs or compressed payloads handed to exec, escape-heavy strings, PyArmor).",
|
|
69
|
+
"ai-cli-invoke": "Runs an AI coding CLI (claude, gemini, q) through a subprocess, the pattern used by the s1ngularity attack to search the disk for secrets.",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Why a capability matters, worded for the package's ecosystem. */
|
|
73
|
+
export const capabilityWhy = (id, ecosystem) => (ecosystem === "pypi" && PY_WHY[id]) || CAPABILITIES[id]?.why || id;
|
|
74
|
+
|
|
53
75
|
/** Stable display order. */
|
|
54
76
|
export const CAPABILITY_ORDER = Object.keys(CAPABILITIES);
|
|
55
77
|
|
|
@@ -193,20 +215,25 @@ const GH_TOKEN_CMD_RE = new RegExp(j("\\bgh\\s+auth\\s+", "token\\b"));
|
|
|
193
215
|
const GH_CRED_FILE_RE = new RegExp(`(?:${j("\\.git-", "credentials")}|${j("gh[/\\\\\\\\]", "hosts\\.yml")})`);
|
|
194
216
|
const GH_TOKEN_REGEX_RE = new RegExp(`(?:${j("gh", "p_")}|${j("gh", "o_")}|${j("github", "_pat_")}|${j("gh", "\\[pousr")}|${j("gh", "\\[ps")})`);
|
|
195
217
|
|
|
218
|
+
/** Attack-indicator patterns shared with the Python scanner (lib/deps/python). */
|
|
219
|
+
export const INDICATORS = {
|
|
220
|
+
AI_CONFIG_RE, DESKTOP_CONFIG_RE, AI_CLIS, AI_CLI_FLAG_RE, GH_TOKEN_CMD_RE, GH_CRED_FILE_RE, GH_TOKEN_REGEX_RE,
|
|
221
|
+
};
|
|
222
|
+
|
|
196
223
|
const DECODE_RE = /\batob\s*\(|\bBuffer\.from\([^)]*['"`](?:base64|hex)['"`]|String\.fromCharCode\s*\(|\bdecodeURIComponent\s*\(\s*escape\s*\(/;
|
|
197
224
|
|
|
198
225
|
const SUSPICIOUS_INSTALL_CMD = /\b(?:curl|wget|powershell|Invoke-WebRequest|iwr)\b|\|\s*(?:ba)?sh\b|base64\s+(?:-d|--decode)/i;
|
|
199
|
-
const RISKY_FOR_INSTALL = ["network", "eval", "obfuscated", "env-bulk", "ai-config-write", "ai-cli-invoke", "token-harvest"];
|
|
226
|
+
export const RISKY_FOR_INSTALL = ["network", "eval", "obfuscated", "env-bulk", "ai-config-write", "ai-cli-invoke", "token-harvest"];
|
|
200
227
|
|
|
201
228
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
202
229
|
|
|
203
|
-
function snippet(line, idx) {
|
|
230
|
+
export function snippet(line, idx) {
|
|
204
231
|
const start = Math.max(0, idx - 40);
|
|
205
232
|
const s = line.slice(start, start + 120).trim();
|
|
206
233
|
return (start > 0 ? "…" : "") + s + (start + 120 < line.length ? "…" : "");
|
|
207
234
|
}
|
|
208
235
|
|
|
209
|
-
function add(caps, id, ev) {
|
|
236
|
+
export function add(caps, id, ev) {
|
|
210
237
|
const list = caps[id] || (caps[id] = { count: 0, evidence: [] });
|
|
211
238
|
list.count++;
|
|
212
239
|
if (list.evidence.length < MAX_EVIDENCE) list.evidence.push(ev);
|
|
@@ -228,7 +255,7 @@ export function entropy(s) {
|
|
|
228
255
|
* Obfuscation signals for one file (comment-blanked code).
|
|
229
256
|
* Returns { flagged, signals: string[], line }.
|
|
230
257
|
*/
|
|
231
|
-
export function obfuscationSignals(code, { hasDynamicCode = false } = {}) {
|
|
258
|
+
export function obfuscationSignals(code, { hasDynamicCode = false, evalRes = P.eval, decodeRe = DECODE_RE, strongEscapes = true } = {}) {
|
|
232
259
|
const signals = [];
|
|
233
260
|
let line = 1;
|
|
234
261
|
let strong = 0;
|
|
@@ -243,7 +270,7 @@ export function obfuscationSignals(code, { hasDynamicCode = false } = {}) {
|
|
|
243
270
|
// Printable characters only: ANSI colour codes (\x1b) and control bytes are normal.
|
|
244
271
|
const hexEsc = code.match(/\\x[2-7][0-9a-fA-F]/g);
|
|
245
272
|
if (hexEsc && hexEsc.length >= 50 && (hexEsc.length * 4) / code.length >= 0.05) {
|
|
246
|
-
strong++;
|
|
273
|
+
if (strongEscapes) strong++;
|
|
247
274
|
signals.push(`\\x escape density ${Math.round(((hexEsc.length * 4) / code.length) * 100)}%`);
|
|
248
275
|
if (line === 1) line = lineOf(code, code.indexOf("\\x"));
|
|
249
276
|
}
|
|
@@ -273,8 +300,8 @@ export function obfuscationSignals(code, { hasDynamicCode = false } = {}) {
|
|
|
273
300
|
let payload = null;
|
|
274
301
|
if (blobs.length && hasDynamicCode) {
|
|
275
302
|
const offsets = (res) => res.flatMap((re) => [...code.matchAll(new RegExp(re.source, "g"))].map((m) => m.index));
|
|
276
|
-
const evals = offsets(
|
|
277
|
-
const decodes = offsets([
|
|
303
|
+
const evals = offsets(evalRes);
|
|
304
|
+
const decodes = offsets([decodeRe]);
|
|
278
305
|
const near = (b, list) => list.some((o) => o >= b.index - PAYLOAD_WINDOW && o <= b.end + PAYLOAD_WINDOW);
|
|
279
306
|
payload = blobs.find((b) => near(b, evals) && near(b, decodes)) || null;
|
|
280
307
|
}
|
|
@@ -287,14 +314,14 @@ export function obfuscationSignals(code, { hasDynamicCode = false } = {}) {
|
|
|
287
314
|
return { flagged, signals, line };
|
|
288
315
|
}
|
|
289
316
|
|
|
290
|
-
function lineOf(code, idx) {
|
|
317
|
+
export function lineOf(code, idx) {
|
|
291
318
|
if (idx <= 0) return 1;
|
|
292
319
|
let n = 1;
|
|
293
320
|
for (let i = 0; i < idx; i++) if (code.charCodeAt(i) === 10) n++;
|
|
294
321
|
return n;
|
|
295
322
|
}
|
|
296
323
|
|
|
297
|
-
function findLine(text, needle) {
|
|
324
|
+
export function findLine(text, needle) {
|
|
298
325
|
const lines = text.split("\n");
|
|
299
326
|
for (let i = 0; i < lines.length; i++) if (lines[i].includes(needle)) return i + 1;
|
|
300
327
|
return 1;
|
|
@@ -393,7 +420,7 @@ export function scanSource(src, file) {
|
|
|
393
420
|
return { caps, exfil, aiConfigProject };
|
|
394
421
|
}
|
|
395
422
|
|
|
396
|
-
function mergeCaps(into, from) {
|
|
423
|
+
export function mergeCaps(into, from) {
|
|
397
424
|
for (const [id, v] of Object.entries(from)) {
|
|
398
425
|
const t = into[id] || (into[id] = { count: 0, evidence: [] });
|
|
399
426
|
t.count += v.count;
|
|
@@ -426,7 +453,9 @@ export function nodeScriptTargets(cmd) {
|
|
|
426
453
|
* Scan a package through a source (see source.js).
|
|
427
454
|
* @param {{ files(): string[], read(rel): Buffer, size(rel): number }} source
|
|
428
455
|
* @param {{ prefix?: string }} opts prefix is prepended to evidence paths (e.g. node_modules/foo/)
|
|
429
|
-
* @returns {{ name, version, capabilities, installScripts, exfil, filesScanned, skipped }}
|
|
456
|
+
* @returns {{ name, version, capabilities, installScripts, exfil, filesScanned, skipped, unscanned }}
|
|
457
|
+
* unscanned: why the package could not be fully read (archive anomaly, no
|
|
458
|
+
* package.json), else null. Callers must not treat such a package as clean.
|
|
430
459
|
*/
|
|
431
460
|
export function scanPackage(source, opts = {}) {
|
|
432
461
|
const prefix = opts.prefix || "";
|
|
@@ -434,10 +463,17 @@ export function scanPackage(source, opts = {}) {
|
|
|
434
463
|
const files = source.files();
|
|
435
464
|
let pkg = {};
|
|
436
465
|
let pkgText = "";
|
|
466
|
+
// Install scripts come only from package.json, so a package whose archive or
|
|
467
|
+
// manifest could not be read is unscanned, never clean (fail closed).
|
|
468
|
+
let unscanned = source.anomaly || null;
|
|
437
469
|
try {
|
|
438
470
|
pkgText = source.read("package.json").toString("utf-8");
|
|
439
471
|
pkg = JSON.parse(pkgText);
|
|
440
|
-
|
|
472
|
+
if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) throw new Error("not an object");
|
|
473
|
+
} catch {
|
|
474
|
+
pkg = {};
|
|
475
|
+
unscanned = unscanned || "package.json is missing or not valid JSON, so its install scripts are unknown";
|
|
476
|
+
}
|
|
441
477
|
|
|
442
478
|
const installScripts = {};
|
|
443
479
|
for (const hook of INSTALL_HOOKS) {
|
|
@@ -519,5 +555,6 @@ export function scanPackage(source, opts = {}) {
|
|
|
519
555
|
exfil,
|
|
520
556
|
filesScanned,
|
|
521
557
|
skipped,
|
|
558
|
+
unscanned,
|
|
522
559
|
};
|
|
523
560
|
}
|