@blamejs/exceptd-skills 0.12.15 → 0.12.18
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 +136 -0
- package/bin/exceptd.js +395 -20
- package/data/_indexes/_meta.json +3 -3
- package/data/cve-catalog.json +1 -1
- package/data/playbooks/ai-api.json +27 -5
- package/data/playbooks/containers.json +34 -7
- package/data/playbooks/cred-stores.json +21 -4
- package/data/playbooks/crypto-codebase.json +29 -14
- package/data/playbooks/crypto.json +13 -3
- package/data/playbooks/framework.json +15 -3
- package/data/playbooks/hardening.json +24 -5
- package/data/playbooks/kernel.json +13 -3
- package/data/playbooks/library-author.json +21 -10
- package/data/playbooks/mcp.json +16 -4
- package/data/playbooks/runtime.json +20 -4
- package/data/playbooks/sbom.json +18 -5
- package/data/playbooks/secrets.json +33 -6
- package/lib/auto-discovery.js +70 -32
- package/lib/cve-curation.js +15 -9
- package/lib/prefetch.js +30 -8
- package/lib/refresh-network.js +40 -0
- package/lib/schemas/playbook.schema.json +7 -1
- package/lib/scoring.js +171 -11
- package/lib/sign.js +163 -2
- package/lib/validate-playbooks.js +46 -0
- package/lib/verify.js +149 -2
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +45 -40
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/verify-shipped-tarball.js +35 -6
|
@@ -43,7 +43,10 @@
|
|
|
43
43
|
"on_fail": "warn"
|
|
44
44
|
}
|
|
45
45
|
],
|
|
46
|
-
"mutex": [
|
|
46
|
+
"mutex": [
|
|
47
|
+
"secrets",
|
|
48
|
+
"containers"
|
|
49
|
+
],
|
|
47
50
|
"feeds_into": [
|
|
48
51
|
{
|
|
49
52
|
"playbook_id": "sbom",
|
|
@@ -563,14 +566,14 @@
|
|
|
563
566
|
},
|
|
564
567
|
{
|
|
565
568
|
"id": "signing-key-material",
|
|
566
|
-
"type": "
|
|
569
|
+
"type": "file",
|
|
567
570
|
"source": "Locate: cosign.pub, *.pem (public-key signing artifacts), keys/public.pem, .well-known/openpgpkey/, sigstore-cosign metadata in workflow",
|
|
568
571
|
"description": "Public signing material committed to the repo or referenced from the release workflow.",
|
|
569
572
|
"required": false
|
|
570
573
|
},
|
|
571
574
|
{
|
|
572
575
|
"id": "intoto-attestations",
|
|
573
|
-
"type": "
|
|
576
|
+
"type": "file",
|
|
574
577
|
"source": "Glob: *.intoto.jsonl, *.sigstore, *.sbom.json.sig, *.cdx.json.sig, attestations directory; AND for npm packages: GET https://registry.npmjs.org/${pkg} and inspect dist.attestations",
|
|
575
578
|
"description": "in-toto / Sigstore / SLSA provenance attestations attached to releases.",
|
|
576
579
|
"required": false,
|
|
@@ -592,7 +595,7 @@
|
|
|
592
595
|
},
|
|
593
596
|
{
|
|
594
597
|
"id": "vendored-code",
|
|
595
|
-
"type": "
|
|
598
|
+
"type": "file",
|
|
596
599
|
"source": "List directories: vendor/, third_party/, external/, deps/, internal/vendor/; AND for each: locate provenance manifest (vendor.json, THIRD_PARTY_PROVENANCE.md, modules.txt for Go vendored)",
|
|
597
600
|
"description": "Vendored / bundled third-party code carried in the publisher's own release.",
|
|
598
601
|
"required": false
|
|
@@ -644,7 +647,7 @@
|
|
|
644
647
|
},
|
|
645
648
|
{
|
|
646
649
|
"id": "skill-signing-infrastructure",
|
|
647
|
-
"type": "
|
|
650
|
+
"type": "file",
|
|
648
651
|
"source": "If repo ships skills / plugins / extensions: locate lib/sign.js, lib/verify.js, scripts/sign-skills.*, signatures/, keys/, AND read package.json scripts for sign/verify entries",
|
|
649
652
|
"description": "Specific to AI-tool / plugin / skill publishers: Ed25519 / cosign signing scaffolding.",
|
|
650
653
|
"required": false
|
|
@@ -786,7 +789,11 @@
|
|
|
786
789
|
"description": "Mutable action reference — upstream owner can substitute action code under the same tag.",
|
|
787
790
|
"confidence": "deterministic",
|
|
788
791
|
"deterministic": true,
|
|
789
|
-
"attack_ref": "T1195.001"
|
|
792
|
+
"attack_ref": "T1195.001",
|
|
793
|
+
"false_positive_checks_required": [
|
|
794
|
+
"If Dependabot is configured (.github/dependabot.yml with package-ecosystem: github-actions, schedule >= weekly) AND the repo has a recent (within last 60 days) Dependabot PR updating action SHAs — the mutable-ref window is bounded; demote to lower-confidence finding (not miss; tag-pinning is still strictly safer than SHA-pinning-with-Dependabot).",
|
|
795
|
+
"If every mutable ref points to a github-owned action (actions/*, github/*) the supply-chain risk is materially lower than third-party action refs; tag-with-rationale-in-comment is acceptable for github-owned. Demote third-party-only mutable refs above github-owned ones in reporting."
|
|
796
|
+
]
|
|
790
797
|
},
|
|
791
798
|
{
|
|
792
799
|
"id": "package-json-provenance-missing",
|
|
@@ -839,11 +846,15 @@
|
|
|
839
846
|
},
|
|
840
847
|
{
|
|
841
848
|
"id": "tag-protection-absent",
|
|
842
|
-
"type": "
|
|
849
|
+
"type": "api_call_sequence",
|
|
843
850
|
"value": "Within the branch-tag-protection artifact: gh api repos/${owner}/${repo}/tags/protection returns empty array OR 404 AND the release-workflows artifact contains a publish workflow that triggers on push tags: ['v*']",
|
|
844
851
|
"description": "Release tags unprotected (theater #6) — any branch can push v* and trigger publish.",
|
|
845
852
|
"confidence": "deterministic",
|
|
846
|
-
"deterministic": true
|
|
853
|
+
"deterministic": true,
|
|
854
|
+
"false_positive_checks_required": [
|
|
855
|
+
"If repository rulesets (newer model) protect the tag pattern v*.*.* with delete + non-fast-forward + update blocked AND zero bypass actors — the legacy tag-protection-rules API may return empty while protection is in place. Cross-check the rulesets API before flagging.",
|
|
856
|
+
"If the repository is read-only / archived (archived: true via the repo API) — tag protection is moot; demote to miss."
|
|
857
|
+
]
|
|
847
858
|
},
|
|
848
859
|
{
|
|
849
860
|
"id": "release-tag-not-signed",
|
|
@@ -871,7 +882,7 @@
|
|
|
871
882
|
},
|
|
872
883
|
{
|
|
873
884
|
"id": "no-security-txt",
|
|
874
|
-
"type": "
|
|
885
|
+
"type": "api_call_sequence",
|
|
875
886
|
"value": "If product has a primary domain: HTTP 404 / connection failure on /.well-known/security.txt",
|
|
876
887
|
"description": "No RFC 9116 machine-discoverable disclosure path (theater #10).",
|
|
877
888
|
"confidence": "deterministic",
|
|
@@ -879,7 +890,7 @@
|
|
|
879
890
|
},
|
|
880
891
|
{
|
|
881
892
|
"id": "private-vuln-reporting-disabled",
|
|
882
|
-
"type": "
|
|
893
|
+
"type": "api_call_sequence",
|
|
883
894
|
"value": "gh api repos/${owner}/${repo} --jq '.security_and_analysis.private_vulnerability_reporting.status' returns 'disabled'",
|
|
884
895
|
"description": "GitHub private vulnerability reporting off — no OIDC-authenticated disclosure intake.",
|
|
885
896
|
"confidence": "deterministic",
|
package/data/playbooks/mcp.json
CHANGED
|
@@ -440,7 +440,7 @@
|
|
|
440
440
|
},
|
|
441
441
|
{
|
|
442
442
|
"id": "mcp-tool-response-log",
|
|
443
|
-
"type": "
|
|
443
|
+
"type": "log",
|
|
444
444
|
"source": "AI client MCP-protocol logs: ~/.claude/logs/mcp/*.jsonl (Claude Code), ~/.cursor/logs/mcp-*.log (Cursor), ~/.codeium/windsurf/logs/mcp_*.log (Windsurf)",
|
|
445
445
|
"description": "v0.12.6: Verbatim tools/list and tools/call response capture. The only artifact that lets ANSI-escape, Unicode-Tag-smuggling, instruction-coercion-grammar, and sensitive-path-reference indicators fire. If client doesn't log MCP responses, mark inconclusive and recommend enabling MCP request/response verbose logging in client settings.",
|
|
446
446
|
"required": false
|
|
@@ -550,7 +550,11 @@
|
|
|
550
550
|
"value": "ps shows an MCP server process running with effective UID 0 or with elevated capabilities (CAP_SYS_ADMIN, CAP_NET_ADMIN)",
|
|
551
551
|
"description": "MCP server with elevated privileges turns a tool-response RCE into immediate root.",
|
|
552
552
|
"confidence": "deterministic",
|
|
553
|
-
"deterministic": true
|
|
553
|
+
"deterministic": true,
|
|
554
|
+
"false_positive_checks_required": [
|
|
555
|
+
"If the MCP server's documented purpose requires elevated capability (kernel-introspection / host-firewall / docker-control MCP) AND it is on a vendor-published privileged-allowlist with a signed manifest, accept with explicit scope.",
|
|
556
|
+
"Verify root-on-host vs root-in-user-namespace — inspect /proc/<pid>/status Cap* fields and userns mapping. A rootless-container MCP running as in-namespace UID 0 has materially lower blast radius; demote to lower confidence."
|
|
557
|
+
]
|
|
554
558
|
},
|
|
555
559
|
{
|
|
556
560
|
"id": "copilot-yolo-mode-flag",
|
|
@@ -579,7 +583,11 @@
|
|
|
579
583
|
"confidence": "deterministic",
|
|
580
584
|
"deterministic": true,
|
|
581
585
|
"atlas_ref": "AML.T0051",
|
|
582
|
-
"attack_ref": "T1059"
|
|
586
|
+
"attack_ref": "T1059",
|
|
587
|
+
"false_positive_checks_required": [
|
|
588
|
+
"If the MCP tool is a documented terminal-emulation / shell-output renderer (the tool's purpose is to relay terminal output and the host AI assistant renders it inline) AND the escape bytes are SGR-only with no cursor-movement / screen-clear / OSC-8 codes, the escape carries no instruction-coercion payload; demote.",
|
|
589
|
+
"Confirm the escape appears in a tool-output payload AND not in a tools/list metadata field — metadata is the high-leverage smuggling site; output may be expected. If only in output for a documented terminal tool, demote."
|
|
590
|
+
]
|
|
583
591
|
},
|
|
584
592
|
{
|
|
585
593
|
"id": "mcp-response-unicode-tag-smuggling",
|
|
@@ -588,7 +596,11 @@
|
|
|
588
596
|
"description": "Unicode Tag-range smuggling — zero-width to humans, tokenized by the model. Source: Embrace the Red (Rehberger, 2025).",
|
|
589
597
|
"confidence": "deterministic",
|
|
590
598
|
"deterministic": true,
|
|
591
|
-
"atlas_ref": "AML.T0051"
|
|
599
|
+
"atlas_ref": "AML.T0051",
|
|
600
|
+
"false_positive_checks_required": [
|
|
601
|
+
"Tag-range codepoints have no documented legitimate use in MCP transport. Confirm the file content is actually a JSON payload (not, e.g., a captured raw email body that contains a U+E0000-tagged language hint per RFC 4646 / RFC 5646 BCP-47 legacy). For genuine MCP payloads, presence is unambiguous; demote only if the payload is from a non-MCP capture mislabeled in the artifact.",
|
|
602
|
+
"Cross-check against the MCP server's package signature / publisher field — if the server is signed by a first-party allowlisted publisher AND the codepoint appears only in a documented `language-tag` schema field, treat as legacy-language-tag (RFC 6082 marked Tag deprecated 2011); still flag for review."
|
|
603
|
+
]
|
|
592
604
|
},
|
|
593
605
|
{
|
|
594
606
|
"id": "mcp-response-instruction-coercion",
|
|
@@ -68,7 +68,9 @@
|
|
|
68
68
|
"T1068",
|
|
69
69
|
"T1548.003"
|
|
70
70
|
],
|
|
71
|
-
"cve_refs": [
|
|
71
|
+
"cve_refs": [
|
|
72
|
+
"CVE-2026-31431"
|
|
73
|
+
],
|
|
72
74
|
"cwe_refs": [
|
|
73
75
|
"CWE-269",
|
|
74
76
|
"CWE-732",
|
|
@@ -428,7 +430,12 @@
|
|
|
428
430
|
"description": "Multiple UID=0 accounts. T1136.001 persistence pattern. Outside legitimate installs.",
|
|
429
431
|
"confidence": "deterministic",
|
|
430
432
|
"deterministic": true,
|
|
431
|
-
"attack_ref": "T1136.001"
|
|
433
|
+
"attack_ref": "T1136.001",
|
|
434
|
+
"false_positive_checks_required": [
|
|
435
|
+
"If the second UID-0 account is `toor` on FreeBSD / `tor` on certain BSDs (documented alternate-shell root) or is a vendor-documented sudo-replacement account (e.g. AlmaLinux's `almauser`, Amazon Linux's `ec2-user` aliased), check the OS-vendor documentation; demote if intentional.",
|
|
436
|
+
"Cross-check `/etc/shadow` for the second account — if the password field is `!` / `*` (locked) AND no SSH authorized_keys / kerberos principal exists, the account is non-authentication-bearing; report at high but not deterministic.",
|
|
437
|
+
"If the host is a documented break-glass jumpbox with a renamed-root account replacing `root` (and `root` itself is removed), this is intentional; accept with the runbook reference."
|
|
438
|
+
]
|
|
432
439
|
},
|
|
433
440
|
{
|
|
434
441
|
"id": "listening-socket-unknown-bind",
|
|
@@ -455,7 +462,11 @@
|
|
|
455
462
|
"description": "Hijack-execution-flow primitive. Any non-root process can replace the file.",
|
|
456
463
|
"confidence": "deterministic",
|
|
457
464
|
"deterministic": true,
|
|
458
|
-
"attack_ref": "T1574.005"
|
|
465
|
+
"attack_ref": "T1574.005",
|
|
466
|
+
"false_positive_checks_required": [
|
|
467
|
+
"Verify the file has the sticky bit set (1777-style mode on /opt subdirs that intentionally permit per-user write). Sticky-bit mode 1777 + ownership root:root limits cross-user replacement and is documented for some package-managers; demote to lower confidence.",
|
|
468
|
+
"If the file is a 0-byte stamp / unix-socket / FIFO under /opt that's documented for the application (e.g. mode 0666 socket for /opt/vendor/sock), the indicator is misclassified by the gatherer; demote."
|
|
469
|
+
]
|
|
459
470
|
},
|
|
460
471
|
{
|
|
461
472
|
"id": "orphan-privileged-process",
|
|
@@ -464,7 +475,12 @@
|
|
|
464
475
|
"description": "Privileged orphan in a writable temp path. Common implant shape.",
|
|
465
476
|
"confidence": "deterministic",
|
|
466
477
|
"deterministic": true,
|
|
467
|
-
"attack_ref": "T1055"
|
|
478
|
+
"attack_ref": "T1055",
|
|
479
|
+
"false_positive_checks_required": [
|
|
480
|
+
"If the process executable matches a known anti-malware / VPN-client / EDR-agent that documentedly drops a binary under /var/tmp for sandbox-extraction reasons (e.g. CrowdStrike Falcon, SentinelOne, MS Defender Linux), check the org's endpoint-agent allowlist; demote if matched.",
|
|
481
|
+
"Verify the binary checksum (sha256) against the org's allowlisted-deploys inventory. A known-hash match means the binary is approved; demote. A mismatch means the path is unsanctioned and the indicator stands.",
|
|
482
|
+
"If the process was launched by `nohup` / `setsid` / `at` from an interactive session and is documented in the system's `at` queue or operator scratch list, accept as ephemeral with a TTL."
|
|
483
|
+
]
|
|
468
484
|
}
|
|
469
485
|
],
|
|
470
486
|
"false_positive_profile": [
|
package/data/playbooks/sbom.json
CHANGED
|
@@ -499,7 +499,7 @@
|
|
|
499
499
|
},
|
|
500
500
|
{
|
|
501
501
|
"id": "model-weight-files",
|
|
502
|
-
"type": "
|
|
502
|
+
"type": "file",
|
|
503
503
|
"source": "find $HOME ~/.cache/huggingface ~/.cache/torch /var/lib/model-store -type f \\( -name '*.pt' -o -name '*.ckpt' -o -name '*.bin' -o -name '*.safetensors' -o -name '*.gguf' \\) 2>/dev/null AND for each: file <path> AND attempt to identify Sigstore signature / OpenSSF model-signing attestation",
|
|
504
504
|
"description": "Model weight artifacts — needed for AI-supply-chain analysis (ATLAS AML.T0018).",
|
|
505
505
|
"required": false
|
|
@@ -528,7 +528,7 @@
|
|
|
528
528
|
},
|
|
529
529
|
{
|
|
530
530
|
"id": "tanstack-payload-sweep",
|
|
531
|
-
"type": "
|
|
531
|
+
"type": "file",
|
|
532
532
|
"source": "find node_modules -path '*/@tanstack/*' \\( -name 'router_init.js' -o -name 'router_runtime.js' \\) 2>/dev/null",
|
|
533
533
|
"description": "CVE-2026-45321 IoC sweep — payload markers inside any installed @tanstack/* package. Captures both flat npm and pnpm-style nested layouts.",
|
|
534
534
|
"required": false
|
|
@@ -639,7 +639,11 @@
|
|
|
639
639
|
"value": "Within the repo-lockfiles artifact: any pinned dependency lacks an integrity field (sha512/sha384/sha256, sri-integrity, go.sum-style hash)",
|
|
640
640
|
"description": "Theater fingerprint #2 detection — name-pinned without integrity.",
|
|
641
641
|
"confidence": "deterministic",
|
|
642
|
-
"deterministic": true
|
|
642
|
+
"deterministic": true,
|
|
643
|
+
"false_positive_checks_required": [
|
|
644
|
+
"If the missing-integrity entries are local-path / workspace / git+ssh refs (`file:`, `link:`, `workspace:`, `git+ssh:`) that the package manager intentionally omits integrity for, demote — workspace refs are integrity-checked at the monorepo root.",
|
|
645
|
+
"Confirm the lockfile is the one the build actually consumes — repos can have stale lockfiles in `archive/` or `pre-migration/` subdirs. Check the build script's referenced lockfile path."
|
|
646
|
+
]
|
|
643
647
|
},
|
|
644
648
|
{
|
|
645
649
|
"id": "transitive-deps-incomplete-sbom",
|
|
@@ -681,7 +685,11 @@
|
|
|
681
685
|
"description": "Direct match for CVE-2026-30615.",
|
|
682
686
|
"confidence": "deterministic",
|
|
683
687
|
"deterministic": true,
|
|
684
|
-
"attack_ref": "T1190"
|
|
688
|
+
"attack_ref": "T1190",
|
|
689
|
+
"false_positive_checks_required": [
|
|
690
|
+
"If the Windsurf binary is the vendor-published patched build identified by sha256 hash (cross-reference Codeium's signed release manifest), the recorded version string may not advance until the next major release; demote when the binary hash matches the patched-build allowlist.",
|
|
691
|
+
"Confirm the installation is on a network-isolated jumpbox / air-gapped dev workstation with documented offline procedure — exploit primitive requires network egress to attacker C2. Demote when the host is provably air-gapped."
|
|
692
|
+
]
|
|
685
693
|
},
|
|
686
694
|
{
|
|
687
695
|
"id": "kev-listed-match",
|
|
@@ -689,7 +697,12 @@
|
|
|
689
697
|
"value": "Any package-matches-catalogued-cve match resolves to a CVE with cisa_kev=true in the catalog",
|
|
690
698
|
"description": "KEV-listed match — fast-path escalation required.",
|
|
691
699
|
"confidence": "deterministic",
|
|
692
|
-
"deterministic": true
|
|
700
|
+
"deterministic": true,
|
|
701
|
+
"false_positive_checks_required": [
|
|
702
|
+
"Pull the vex-statements artifact for the matched CVE. If a VEX entry exists with status `not_affected` (justification: `component_not_present` / `vulnerable_code_not_in_execute_path` / `vulnerable_code_cannot_be_controlled_by_adversary`) AND `status_notes` references a maintainer review, demote — but keep on the regression schedule.",
|
|
703
|
+
"Verify the matched package version is actually present in the live deploy (not just in a build-time/dev-time dependency) — runtime presence elevates; build-time-only presence demotes to high.",
|
|
704
|
+
"If the matched VEX entry has `status: fixed` AND the lockfile resolution is newer-than-fix-version, the match is stale and should be demoted; re-run the dependency walker to refresh."
|
|
705
|
+
]
|
|
693
706
|
},
|
|
694
707
|
{
|
|
695
708
|
"id": "tanstack-worm-payload-files",
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
"on_fail": "warn"
|
|
36
36
|
}
|
|
37
37
|
],
|
|
38
|
-
"mutex": [
|
|
38
|
+
"mutex": [
|
|
39
|
+
"library-author"
|
|
40
|
+
],
|
|
39
41
|
"feeds_into": [
|
|
40
42
|
{
|
|
41
43
|
"playbook_id": "cred-stores",
|
|
@@ -389,7 +391,12 @@
|
|
|
389
391
|
"description": "AWS Secret Access Key. Always findable in tandem with AKIA*. Independent finding because both halves are needed.",
|
|
390
392
|
"confidence": "deterministic",
|
|
391
393
|
"deterministic": true,
|
|
392
|
-
"attack_ref": "T1552.001"
|
|
394
|
+
"attack_ref": "T1552.001",
|
|
395
|
+
"false_positive_checks_required": [
|
|
396
|
+
"Check for co-occurrence with an AKIA*/ASIA*/AGPA*/AIDA* access-key-id in a 10-line window. Without corroboration, the 40-char base64-ish string is plausibly a Base64-encoded JWT signature / random data / DB password — demote to medium when corroboration is absent.",
|
|
397
|
+
"If the value matches the AWS-published sample wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY, demote to miss (doc fixture).",
|
|
398
|
+
"Verify the file is not under examples/ / docs/ / fixtures/ AND not a known-test snapshot; demote when in a documented placeholder path."
|
|
399
|
+
]
|
|
393
400
|
},
|
|
394
401
|
{
|
|
395
402
|
"id": "gcp-service-account-json",
|
|
@@ -425,7 +432,12 @@
|
|
|
425
432
|
"description": "Slack bot / user / refresh / app token.",
|
|
426
433
|
"confidence": "deterministic",
|
|
427
434
|
"deterministic": true,
|
|
428
|
-
"attack_ref": "T1552.001"
|
|
435
|
+
"attack_ref": "T1552.001",
|
|
436
|
+
"false_positive_checks_required": [
|
|
437
|
+
"If the value is `xoxb-PLACEHOLDER` / `xoxb-EXAMPLE` / a Slack-published doc fixture (e.g. xoxb-12345-67890-AbCdEf), demote.",
|
|
438
|
+
"Verify the token format conforms to a current Slack token shape (xoxb- usually has at least three dash-separated numeric segments before the alpha suffix). Loose 10-char tail matches without segmentation can match unrelated strings; demote when segmentation is wrong.",
|
|
439
|
+
"If under examples/ / docs/ / fixtures/ AND no surrounding env var (SLACK_BOT_TOKEN / SLACK_USER_TOKEN) suggests an active config, demote."
|
|
440
|
+
]
|
|
429
441
|
},
|
|
430
442
|
{
|
|
431
443
|
"id": "stripe-secret-key",
|
|
@@ -434,7 +446,12 @@
|
|
|
434
446
|
"description": "Stripe live or test secret key. Live keys are direct financial exposure.",
|
|
435
447
|
"confidence": "deterministic",
|
|
436
448
|
"deterministic": true,
|
|
437
|
-
"attack_ref": "T1552.001"
|
|
449
|
+
"attack_ref": "T1552.001",
|
|
450
|
+
"false_positive_checks_required": [
|
|
451
|
+
"If the prefix is `sk_test_` AND the value matches a published Stripe sample test key (see Stripe API docs — they publish a small set explicitly for documentation use), demote — test keys cannot move funds.",
|
|
452
|
+
"Verify the file path is not under examples/, fixtures/, docs/, or a stripe-quickstart template — demote in documented placeholder paths.",
|
|
453
|
+
"For sk_live_*, cross-check against the Stripe API (`POST /v1/accounts/retrieve` or similar live-validity probe) only if the operator has authorised an external validation; otherwise treat the live prefix as deterministic."
|
|
454
|
+
]
|
|
438
455
|
},
|
|
439
456
|
{
|
|
440
457
|
"id": "jwt-token-with-secret-context",
|
|
@@ -461,7 +478,12 @@
|
|
|
461
478
|
"description": "OpenAI API key (incl. project-scoped sk-proj-* form). Active key spend exposure.",
|
|
462
479
|
"confidence": "deterministic",
|
|
463
480
|
"deterministic": true,
|
|
464
|
-
"attack_ref": "T1552.001"
|
|
481
|
+
"attack_ref": "T1552.001",
|
|
482
|
+
"false_positive_checks_required": [
|
|
483
|
+
"If the key prefix is `sk-test-*` / `sk-dummy-*` / `sk-XXXX` / contains only placeholder runs (XXXXX, 1234, abcd) it is a documentation fixture; demote.",
|
|
484
|
+
"Verify the key length meets the OpenAI minimum entropy floor (post-prefix length >= 48 chars). Below the floor is a placeholder; demote.",
|
|
485
|
+
"The regex `sk-[A-Za-z0-9_-]{40,}` also matches Anthropic sk-ant-* and other vendor-prefixed keys — confirm the vendor by surrounding context (env var name, comment) before classifying as OpenAI."
|
|
486
|
+
]
|
|
465
487
|
},
|
|
466
488
|
{
|
|
467
489
|
"id": "anthropic-api-key",
|
|
@@ -470,7 +492,12 @@
|
|
|
470
492
|
"description": "Anthropic API key. Active key spend exposure.",
|
|
471
493
|
"confidence": "deterministic",
|
|
472
494
|
"deterministic": true,
|
|
473
|
-
"attack_ref": "T1552.001"
|
|
495
|
+
"attack_ref": "T1552.001",
|
|
496
|
+
"false_positive_checks_required": [
|
|
497
|
+
"If the key is `sk-ant-api03-PLACEHOLDER...` / `sk-ant-test-*` / contains documented placeholder substrings (XXXX runs, all-zero runs), demote.",
|
|
498
|
+
"Verify the file is not under examples/, fixtures/, sdk-quickstart/, or a docs-snippet path — demote when in documented placeholder paths.",
|
|
499
|
+
"Confirm post-prefix length meets the Anthropic minimum entropy floor (>= 80 chars after sk-ant-(api03|admin01)-); below the floor is a fixture, demote."
|
|
500
|
+
]
|
|
474
501
|
},
|
|
475
502
|
{
|
|
476
503
|
"id": "world-writable-env-file",
|
package/lib/auto-discovery.js
CHANGED
|
@@ -31,6 +31,63 @@ const fs = require("fs");
|
|
|
31
31
|
const path = require("path");
|
|
32
32
|
const { scoreCustom } = require("./scoring");
|
|
33
33
|
|
|
34
|
+
// audit M P1-C: stored rwep_factors must reproduce the stored rwep_score.
|
|
35
|
+
// `buildScoringInputs` is the single source of truth for both — it captures
|
|
36
|
+
// the conservative defaults applied to a freshly-imported KEV draft (CISA
|
|
37
|
+
// only lists vulnerabilities with documented exploitation, so we assume a
|
|
38
|
+
// public PoC exists; reboot defaults to true because most KEV-listed CVEs
|
|
39
|
+
// land in the kernel / hypervisor / vendor firmware where reboot is the
|
|
40
|
+
// norm). The same input object is then handed to scoreCustom for the score
|
|
41
|
+
// AND mapped into the `rwep_factors` shape stored on the draft. Calling
|
|
42
|
+
// scoring.validate() on the post-import catalog will no longer flag every
|
|
43
|
+
// auto-imported draft for divergence > 5.
|
|
44
|
+
function buildScoringInputs(kevEntry /*, nvdPayload */) {
|
|
45
|
+
void kevEntry;
|
|
46
|
+
return {
|
|
47
|
+
cisa_kev: true,
|
|
48
|
+
poc_available: true,
|
|
49
|
+
ai_assisted_weapon: false,
|
|
50
|
+
ai_discovered: false,
|
|
51
|
+
active_exploitation: "suspected",
|
|
52
|
+
blast_radius: 15,
|
|
53
|
+
patch_available: false,
|
|
54
|
+
live_patch_available: false,
|
|
55
|
+
reboot_required: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Audit M P3-O — diff severity nuance for KEV-discovered drafts.
|
|
61
|
+
*
|
|
62
|
+
* Pre-fix every KEV-derived diff carried `severity: "high"`. Operators
|
|
63
|
+
* scanning the diff stream had no way to distinguish "patch in 21 days"
|
|
64
|
+
* from "active ransomware campaign, patch yesterday." Now:
|
|
65
|
+
*
|
|
66
|
+
* - ransomware_use === "Known" → "critical" (campaigns observed in the wild)
|
|
67
|
+
* - dueDate within 7 days of now → "critical" (CISA escalation window)
|
|
68
|
+
* - otherwise → "high" (still actively exploited per KEV listing)
|
|
69
|
+
*
|
|
70
|
+
* A KEV listing inherently means active exploitation; "low" / "medium"
|
|
71
|
+
* never apply here. The split is between "act today" and "act this sprint."
|
|
72
|
+
*
|
|
73
|
+
* @param {object} kevEntry
|
|
74
|
+
* @returns {"critical" | "high"}
|
|
75
|
+
*/
|
|
76
|
+
function deriveKevSeverity(kevEntry) {
|
|
77
|
+
const ransomware = String(kevEntry?.knownRansomwareCampaignUse || "").toLowerCase() === "known";
|
|
78
|
+
if (ransomware) return "critical";
|
|
79
|
+
const due = kevEntry?.dueDate;
|
|
80
|
+
if (typeof due === "string" && /^\d{4}-\d{2}-\d{2}/.test(due)) {
|
|
81
|
+
const dueMs = Date.parse(due);
|
|
82
|
+
if (Number.isFinite(dueMs)) {
|
|
83
|
+
const deltaMs = dueMs - Date.now();
|
|
84
|
+
// Within the next 7 days OR already past due → critical.
|
|
85
|
+
if (deltaMs <= 7 * 86_400_000) return "critical";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return "high";
|
|
89
|
+
}
|
|
90
|
+
|
|
34
91
|
const TODAY = new Date().toISOString().slice(0, 10);
|
|
35
92
|
const TIMEOUT_MS = 10_000;
|
|
36
93
|
const USER_AGENT = "exceptd-security/auto-discovery (+https://exceptd.com)";
|
|
@@ -110,37 +167,17 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
|
|
|
110
167
|
const knownRansomware =
|
|
111
168
|
String(kevEntry.knownRansomwareCampaignUse || "").toLowerCase() === "known";
|
|
112
169
|
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
patch_available: null,
|
|
125
|
-
live_patch_available: null,
|
|
126
|
-
reboot_required: null,
|
|
127
|
-
};
|
|
128
|
-
// scoreCustom() treats null fields as false, which under-counts the
|
|
129
|
-
// score. Pass concrete defaults for unknowns: poc_available=true is
|
|
130
|
-
// the conservative assumption for KEV entries (CISA generally only
|
|
131
|
-
// adds entries with documented exploitation), and reboot_required=
|
|
132
|
-
// true biases toward urgency.
|
|
133
|
-
const rwep_score = scoreCustom({
|
|
134
|
-
cisa_kev: true,
|
|
135
|
-
poc_available: true,
|
|
136
|
-
ai_assisted_weapon: false,
|
|
137
|
-
ai_discovered: false,
|
|
138
|
-
active_exploitation: "suspected",
|
|
139
|
-
blast_radius: 15,
|
|
140
|
-
patch_available: false,
|
|
141
|
-
live_patch_available: false,
|
|
142
|
-
reboot_required: true,
|
|
143
|
-
});
|
|
170
|
+
// audit M P1-C: stored rwep_factors and computed rwep_score MUST agree.
|
|
171
|
+
// Previously rwep_factors held nulls (for unknown poc/ai/reboot) but
|
|
172
|
+
// rwep_score was computed from concrete defaults (poc=true, reboot=true).
|
|
173
|
+
// `scoring.validate()` then flagged every auto-imported draft for
|
|
174
|
+
// divergence > 5. Now: one canonical input object → both surfaces.
|
|
175
|
+
// The curation flow rewrites these once an operator answers the editorial
|
|
176
|
+
// questions; until then, the boolean shape on rwep_factors is the
|
|
177
|
+
// conservative-default snapshot and reproduces the score exactly.
|
|
178
|
+
const scoringInputs = buildScoringInputs(kevEntry, nvdPayload);
|
|
179
|
+
const rwep_factors = { ...scoringInputs };
|
|
180
|
+
const rwep_score = scoreCustom(scoringInputs);
|
|
144
181
|
|
|
145
182
|
const product = [kevEntry.vendorProject, kevEntry.product]
|
|
146
183
|
.filter(Boolean)
|
|
@@ -254,7 +291,7 @@ function discoverNewKev(ctx, cap = DEFAULT_CAP) {
|
|
|
254
291
|
op: "add",
|
|
255
292
|
target: "cveCatalog",
|
|
256
293
|
entry,
|
|
257
|
-
severity:
|
|
294
|
+
severity: deriveKevSeverity(kev),
|
|
258
295
|
meta: {
|
|
259
296
|
date_added: kev.dateAdded || null,
|
|
260
297
|
vendor: kev.vendorProject || null,
|
|
@@ -529,6 +566,7 @@ module.exports = {
|
|
|
529
566
|
discoverNewRfcs,
|
|
530
567
|
buildKevDraftEntry,
|
|
531
568
|
getProjectRfcGroups,
|
|
569
|
+
deriveKevSeverity,
|
|
532
570
|
SEED_RFC_GROUPS,
|
|
533
571
|
DEFAULT_CAP,
|
|
534
572
|
};
|
package/lib/cve-curation.js
CHANGED
|
@@ -43,6 +43,14 @@ const path = require("path");
|
|
|
43
43
|
// before deciding promotion.
|
|
44
44
|
const { withCatalogLock } = require("./refresh-external");
|
|
45
45
|
const { validate: validateAgainstSchema } = require("./validate-cve-catalog");
|
|
46
|
+
// audit J F3: derive rwep_score via the canonical scoring helper rather
|
|
47
|
+
// than a blind `Object.values(...).reduce(sum)`. The helper detects shape
|
|
48
|
+
// (boolean inputs → scoreCustom; post-weight numeric inputs → sum + clamp)
|
|
49
|
+
// so the curation apply-path produces a score that matches whatever the
|
|
50
|
+
// catalog scorer or playbook-runner would have produced for the same
|
|
51
|
+
// factors. Direct dependency on scoring.js is intentional — scoring.js is
|
|
52
|
+
// the authoritative formula.
|
|
53
|
+
const { deriveRwepFromFactors } = require("./scoring");
|
|
46
54
|
|
|
47
55
|
const ROOT = path.resolve(__dirname, "..");
|
|
48
56
|
const CVE_SCHEMA_PATH = path.join(ROOT, "lib", "schemas", "cve-catalog.schema.json");
|
|
@@ -562,17 +570,15 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
|
562
570
|
appliedFields.push(field);
|
|
563
571
|
}
|
|
564
572
|
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
// numeric
|
|
573
|
+
// audit J F3: derive rwep_score via the canonical scoring helper rather
|
|
574
|
+
// than a blind sum. deriveRwepFromFactors detects shape (boolean inputs
|
|
575
|
+
// → scoreCustom; post-weight numeric inputs → sum + clamp) and routes
|
|
576
|
+
// accordingly, so the apply-path produces a score that agrees with
|
|
577
|
+
// scoring.validate() instead of diverging from it.
|
|
568
578
|
if ("rwep_factors" in answers && !("rwep_score" in answers)
|
|
569
579
|
&& entry.rwep_factors && typeof entry.rwep_factors === "object") {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
if (typeof v === "number") sum += v;
|
|
573
|
-
}
|
|
574
|
-
entry.rwep_score = Math.max(0, Math.min(100, sum));
|
|
575
|
-
appliedFields.push("rwep_score (derived from rwep_factors)");
|
|
580
|
+
entry.rwep_score = deriveRwepFromFactors(entry.rwep_factors);
|
|
581
|
+
appliedFields.push("rwep_score (derived from rwep_factors via scoring.deriveRwepFromFactors)");
|
|
576
582
|
}
|
|
577
583
|
|
|
578
584
|
// last_updated reflects the apply moment.
|
package/lib/prefetch.js
CHANGED
|
@@ -15,8 +15,14 @@
|
|
|
15
15
|
* kev/known_exploited_vulnerabilities.json — full KEV feed
|
|
16
16
|
* nvd/<cve-id>.json — NVD 2.0 per-CVE response
|
|
17
17
|
* epss/<cve-id>.json — EPSS per-CVE response
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* rfc/<doc-name>.json — IETF Datatracker doc record
|
|
19
|
+
* pins/<owner>__<repo>__releases.json — MITRE GitHub releases listing
|
|
20
|
+
*
|
|
21
|
+
* audit M P2-K: the registered source names in SOURCES below are `rfc` and
|
|
22
|
+
* `pins`. Earlier comments + --help text said `ietf` and `github`; an
|
|
23
|
+
* operator running `--source ietf` or `--source github` would hit "unknown
|
|
24
|
+
* source" because no such key exists. The names below are the canonical
|
|
25
|
+
* ones consumed by --source filtering.
|
|
20
26
|
*
|
|
21
27
|
* Usage:
|
|
22
28
|
* node lib/prefetch.js # fetch everything not fresh
|
|
@@ -137,8 +143,8 @@ Sources:
|
|
|
137
143
|
kev CISA Known Exploited Vulnerabilities
|
|
138
144
|
nvd NIST NVD 2.0 per-CVE
|
|
139
145
|
epss FIRST EPSS per-CVE
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
rfc IETF Datatracker per-RFC
|
|
147
|
+
pins MITRE GitHub releases (ATLAS / ATT&CK)
|
|
142
148
|
|
|
143
149
|
Options:
|
|
144
150
|
--max-age <dur> skip entries fresher than this (e.g. 12h, 1d). Default: 24h.
|
|
@@ -296,7 +302,15 @@ function isFresh(idx, source, id, maxAgeMs) {
|
|
|
296
302
|
|
|
297
303
|
function authHeadersForSource(source) {
|
|
298
304
|
if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
|
|
299
|
-
|
|
305
|
+
// audit M P2-J: the registered source name for MITRE GitHub releases is
|
|
306
|
+
// `pins` (see SOURCES above). The prior check looked for `github`, so
|
|
307
|
+
// GITHUB_TOKEN never reached the per-request Authorization header and
|
|
308
|
+
// anonymous-rate-limited fetches were always used even when an operator
|
|
309
|
+
// had supplied a token. Accept both spellings so this is forgiving of
|
|
310
|
+
// the historical naming and the registered name.
|
|
311
|
+
if ((source === "pins" || source === "github") && process.env.GITHUB_TOKEN) {
|
|
312
|
+
return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
|
|
313
|
+
}
|
|
300
314
|
return {};
|
|
301
315
|
}
|
|
302
316
|
|
|
@@ -459,13 +473,21 @@ function readCached(cacheDir, source, id, opts = {}) {
|
|
|
459
473
|
const idx = loadIndex(cacheDir);
|
|
460
474
|
const meta = idx.entries[entryKey(source, id)];
|
|
461
475
|
if (!meta) return null;
|
|
462
|
-
|
|
463
|
-
|
|
476
|
+
// audit M P2-L: when `fetched_at` is missing / non-string / unparseable,
|
|
477
|
+
// `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false —
|
|
478
|
+
// so the cached entry would have been returned as if fresh. Treat any
|
|
479
|
+
// non-finite age as "no provenance, refuse" unless the caller explicitly
|
|
480
|
+
// opted into allowStale.
|
|
481
|
+
const ageMs = meta.fetched_at ? Date.now() - new Date(meta.fetched_at).getTime() : NaN;
|
|
482
|
+
if (!opts.allowStale) {
|
|
483
|
+
if (!meta.fetched_at || !Number.isFinite(ageMs)) return null;
|
|
484
|
+
if (ageMs > maxAgeMs) return null;
|
|
485
|
+
}
|
|
464
486
|
const p = entryPath(cacheDir, source, id);
|
|
465
487
|
if (!fs.existsSync(p)) return null;
|
|
466
488
|
try {
|
|
467
489
|
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
468
|
-
return { data, age_ms: ageMs, meta };
|
|
490
|
+
return { data, age_ms: Number.isFinite(ageMs) ? ageMs : null, meta };
|
|
469
491
|
} catch {
|
|
470
492
|
return null;
|
|
471
493
|
}
|
package/lib/refresh-network.js
CHANGED
|
@@ -357,6 +357,46 @@ async function main() {
|
|
|
357
357
|
process.exitCode = 5; return;
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
// v0.12.16 (audit I P1-5): cross-check the local public key against
|
|
361
|
+
// keys/EXPECTED_FINGERPRINT (the CI-pinned signing key). The prior
|
|
362
|
+
// refresh-network code only compared LOCAL ↔ TARBALL fingerprints, so a
|
|
363
|
+
// coordinated attacker who swapped both `keys/public.pem` on the operator's
|
|
364
|
+
// host AND the registry tarball passed every check — fingerprints match
|
|
365
|
+
// each other but match the attacker's key. The pin in EXPECTED_FINGERPRINT
|
|
366
|
+
// is the external trust anchor that closes this gap.
|
|
367
|
+
//
|
|
368
|
+
// Honors `KEYS_ROTATED=1` env to allow legitimate key rotation without
|
|
369
|
+
// re-bootstrap. Missing EXPECTED_FINGERPRINT file → warn-and-continue
|
|
370
|
+
// (don't break existing installs whose tree predates the pin file).
|
|
371
|
+
const expectedFingerprintPath = path.join(ROOT, "keys", "EXPECTED_FINGERPRINT");
|
|
372
|
+
if (fs.existsSync(expectedFingerprintPath) && !process.env.KEYS_ROTATED) {
|
|
373
|
+
try {
|
|
374
|
+
const expectedFp = fs.readFileSync(expectedFingerprintPath, "utf8")
|
|
375
|
+
.split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0);
|
|
376
|
+
// v0.12.16 (codex P1 PR #11): `expectedFp` is read verbatim from
|
|
377
|
+
// keys/EXPECTED_FINGERPRINT (formatted as `SHA256:<base64>`), but
|
|
378
|
+
// `fingerprintPublicKey()` returns the raw base64 without the
|
|
379
|
+
// `SHA256:` prefix. Comparing the two raw strings would refuse every
|
|
380
|
+
// legitimate run unless KEYS_ROTATED=1 was set. Normalize by stripping
|
|
381
|
+
// the prefix from the pin file before compare. lib/verify.js's
|
|
382
|
+
// checkExpectedFingerprint() does the symmetric thing (adds the
|
|
383
|
+
// prefix to localFp); either side works as long as one is canonical.
|
|
384
|
+
const expectedFpBase64 = expectedFp && expectedFp.startsWith("SHA256:")
|
|
385
|
+
? expectedFp.slice("SHA256:".length)
|
|
386
|
+
: expectedFp;
|
|
387
|
+
if (expectedFpBase64 && expectedFpBase64 !== localFp) {
|
|
388
|
+
emit({
|
|
389
|
+
ok: false,
|
|
390
|
+
error: `local keys/public.pem fingerprint diverges from keys/EXPECTED_FINGERPRINT pin`,
|
|
391
|
+
local_fingerprint: "SHA256:" + localFp,
|
|
392
|
+
pinned_fingerprint: expectedFp,
|
|
393
|
+
hint: "Either keys/public.pem was rotated since the pin was set (rerun `npm run bootstrap` to re-pin), or the local public.pem was tampered with. Set KEYS_ROTATED=1 to bypass once. Refusing to swap on --network.",
|
|
394
|
+
}, opts.json);
|
|
395
|
+
process.exitCode = 5; return;
|
|
396
|
+
}
|
|
397
|
+
} catch { /* unreadable pin file = warn-and-continue */ }
|
|
398
|
+
}
|
|
399
|
+
|
|
360
400
|
// Verify every signed entry in the tarball manifest using the local key.
|
|
361
401
|
let tarballManifest;
|
|
362
402
|
try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
|
|
@@ -323,7 +323,13 @@
|
|
|
323
323
|
"confidence": { "type": "string", "enum": ["low", "medium", "high", "deterministic"] },
|
|
324
324
|
"deterministic": { "type": "boolean", "description": "True if presence is definitive proof, not probabilistic." },
|
|
325
325
|
"atlas_ref": { "type": "string" },
|
|
326
|
-
"attack_ref": { "type": "string" }
|
|
326
|
+
"attack_ref": { "type": "string" },
|
|
327
|
+
"cve_ref": { "type": "string", "description": "Optional CVE / MAL-* / SNYK-* identifier this indicator binds to. When the indicator fires, the named entry is pulled into analyze.matched_cves[] (v0.12.14 F3)." },
|
|
328
|
+
"false_positive_checks_required": {
|
|
329
|
+
"type": "array",
|
|
330
|
+
"items": { "type": "string" },
|
|
331
|
+
"description": "v0.12.12+ contract: each entry is a check an AI assistant or operator must satisfy before this indicator's `hit` verdict can drive classification:detected. The runner downgrades a hit to inconclusive when this array is non-empty and no fp_checks attestation is supplied via signal_overrides[`<id>__fp_checks`]."
|
|
332
|
+
}
|
|
327
333
|
}
|
|
328
334
|
}
|
|
329
335
|
},
|