@clear-capabilities/agentic-security-scanner 0.74.0
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 +1580 -0
- package/bin/.agentic-security/findings.json +1577 -0
- package/bin/.agentic-security/last-scan.json +1577 -0
- package/bin/.agentic-security/last-scan.json.sig +1 -0
- package/bin/.agentic-security/scan-history.json +465 -0
- package/bin/.agentic-security/streak.json +25 -0
- package/bin/agentic-security-audit.js +198 -0
- package/bin/agentic-security-consistency.js +80 -0
- package/bin/agentic-security-diff.js +136 -0
- package/bin/agentic-security-lsp.js +12 -0
- package/bin/agentic-security-mcp.js +40 -0
- package/bin/agentic-security-rule.js +153 -0
- package/bin/agentic-security.js +1683 -0
- package/dist/117.index.js +207 -0
- package/dist/178.index.js +250 -0
- package/dist/218.index.js +793 -0
- package/dist/227.index.js +192 -0
- package/dist/301.index.js +167 -0
- package/dist/384.index.js +18 -0
- package/dist/476.index.js +126 -0
- package/dist/513.index.js +373 -0
- package/dist/520.index.js +13 -0
- package/dist/601.index.js +1038 -0
- package/dist/634.index.js +1892 -0
- package/dist/637.index.js +216 -0
- package/dist/660.index.js +131 -0
- package/dist/675.index.js +451 -0
- package/dist/826.index.js +188 -0
- package/dist/830.index.js +133 -0
- package/dist/agentic-security.mjs +272 -0
- package/dist/agentic-security.mjs.sha256 +1 -0
- package/dist/calibration-seed.json +27 -0
- package/package.json +77 -0
- package/src/.agentic-security/findings.json +80844 -0
- package/src/.agentic-security/last-scan.json +80844 -0
- package/src/.agentic-security/last-scan.json.sig +1 -0
- package/src/.agentic-security/scan-history.json +8408 -0
- package/src/.agentic-security/streak.json +26 -0
- package/src/badge.js +188 -0
- package/src/compare.js +203 -0
- package/src/dataflow/.agentic-security/findings.json +3487 -0
- package/src/dataflow/.agentic-security/last-scan.json +3487 -0
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
- package/src/dataflow/.agentic-security/scan-history.json +735 -0
- package/src/dataflow/.agentic-security/streak.json +24 -0
- package/src/dataflow/CLAUDE.md +38 -0
- package/src/dataflow/access-paths.js +172 -0
- package/src/dataflow/async-sequencing.js +177 -0
- package/src/dataflow/backward.js +201 -0
- package/src/dataflow/catalog-expanded.js +485 -0
- package/src/dataflow/catalog.js +659 -0
- package/src/dataflow/cross-repo.js +219 -0
- package/src/dataflow/engine.js +588 -0
- package/src/dataflow/exception-flow.js +116 -0
- package/src/dataflow/exploit-prover.js +187 -0
- package/src/dataflow/higher-order.js +221 -0
- package/src/dataflow/ifds.js +347 -0
- package/src/dataflow/implicit-flow.js +129 -0
- package/src/dataflow/incremental.js +229 -0
- package/src/dataflow/index.js +181 -0
- package/src/dataflow/numeric-domain.js +192 -0
- package/src/dataflow/path-feasibility.js +114 -0
- package/src/dataflow/points-to.js +337 -0
- package/src/dataflow/polyglot.js +190 -0
- package/src/dataflow/proven-clean.js +159 -0
- package/src/dataflow/receiver-context.js +76 -0
- package/src/dataflow/sanitizer-proof.js +154 -0
- package/src/dataflow/soft-taint.js +140 -0
- package/src/dataflow/string-domain.js +234 -0
- package/src/dataflow/stub-aware-filter.js +100 -0
- package/src/dataflow/summaries.js +132 -0
- package/src/dataflow/symbolic-exec.js +238 -0
- package/src/dataflow/tabulation.js +135 -0
- package/src/engine.js +7763 -0
- package/src/history-scan.js +229 -0
- package/src/index.js +3 -0
- package/src/integrations/.agentic-security/findings.json +1504 -0
- package/src/integrations/.agentic-security/last-scan.json +1504 -0
- package/src/integrations/.agentic-security/scan-history.json +40 -0
- package/src/integrations/.agentic-security/streak.json +21 -0
- package/src/integrations/index.js +321 -0
- package/src/integrations/tickets.js +200 -0
- package/src/ir/.agentic-security/findings.json +3036 -0
- package/src/ir/.agentic-security/last-scan.json +3036 -0
- package/src/ir/.agentic-security/last-scan.json.sig +1 -0
- package/src/ir/.agentic-security/scan-history.json +364 -0
- package/src/ir/.agentic-security/streak.json +23 -0
- package/src/ir/CLAUDE.md +172 -0
- package/src/ir/callgraph.js +73 -0
- package/src/ir/class-hierarchy.js +195 -0
- package/src/ir/index.js +152 -0
- package/src/ir/parser-cs.js +260 -0
- package/src/ir/parser-java.js +286 -0
- package/src/ir/parser-js.js +413 -0
- package/src/ir/parser-kt.js +258 -0
- package/src/ir/parser-py-cst.js +136 -0
- package/src/ir/parser-py.helper.py +501 -0
- package/src/ir/parser-py.js +312 -0
- package/src/ir/ssa.js +315 -0
- package/src/ir/type-stubs.js +288 -0
- package/src/leaderboard.js +152 -0
- package/src/llm-validator/.agentic-security/findings.json +1891 -0
- package/src/llm-validator/.agentic-security/last-scan.json +1891 -0
- package/src/llm-validator/.agentic-security/last-scan.json.sig +1 -0
- package/src/llm-validator/.agentic-security/scan-history.json +168 -0
- package/src/llm-validator/.agentic-security/streak.json +20 -0
- package/src/llm-validator/consistency.js +141 -0
- package/src/llm-validator/index.js +437 -0
- package/src/lsp/.agentic-security/findings.json +28 -0
- package/src/lsp/.agentic-security/last-scan.json +28 -0
- package/src/lsp/.agentic-security/scan-history.json +79 -0
- package/src/lsp/.agentic-security/streak.json +22 -0
- package/src/lsp/server.js +275 -0
- package/src/mcp/.agentic-security/findings.json +8358 -0
- package/src/mcp/.agentic-security/last-scan.json +8358 -0
- package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
- package/src/mcp/.agentic-security/scan-history.json +1125 -0
- package/src/mcp/.agentic-security/streak.json +22 -0
- package/src/mcp/CLAUDE.md +54 -0
- package/src/mcp/audit.js +136 -0
- package/src/mcp/redact.js +75 -0
- package/src/mcp/server.js +158 -0
- package/src/mcp/stdio.js +83 -0
- package/src/mcp/tools.js +940 -0
- package/src/mcp/validate.js +49 -0
- package/src/personality.js +164 -0
- package/src/poc-video.js +239 -0
- package/src/posture/.agentic-security/findings.json +51239 -0
- package/src/posture/.agentic-security/last-scan.json +51239 -0
- package/src/posture/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/.agentic-security/scan-history.json +5557 -0
- package/src/posture/.agentic-security/streak.json +24 -0
- package/src/posture/CLAUDE.md +42 -0
- package/src/posture/adversarial-self-test.js +114 -0
- package/src/posture/adversary-agent.js +204 -0
- package/src/posture/agents-memory.js +135 -0
- package/src/posture/ai-code-fingerprint.js +171 -0
- package/src/posture/aibom.js +284 -0
- package/src/posture/api-inventory.js +96 -0
- package/src/posture/attack-playbooks.js +305 -0
- package/src/posture/auditor-agent.js +115 -0
- package/src/posture/auth-posture-import.js +135 -0
- package/src/posture/baseline-compare.js +114 -0
- package/src/posture/blast-radius.js +836 -0
- package/src/posture/bounty-prediction.js +141 -0
- package/src/posture/business-logic.js +239 -0
- package/src/posture/calibration-drift.js +93 -0
- package/src/posture/calibration-seed.json +27 -0
- package/src/posture/calibration.js +204 -0
- package/src/posture/clustering.js +75 -0
- package/src/posture/concurrency-checker.js +265 -0
- package/src/posture/confidence.js +65 -0
- package/src/posture/container-runtime.js +149 -0
- package/src/posture/counterfactual.js +109 -0
- package/src/posture/cross-lang-graphql.js +165 -0
- package/src/posture/cross-lang-grpc.js +166 -0
- package/src/posture/cross-lang-meta.js +101 -0
- package/src/posture/cross-lang-openapi.js +187 -0
- package/src/posture/cross-lang-orm.js +153 -0
- package/src/posture/cross-lang-queues.js +210 -0
- package/src/posture/crown-jewels.js +110 -0
- package/src/posture/custom-rules.js +361 -0
- package/src/posture/cve-alert-daemon.js +433 -0
- package/src/posture/cve-lookup.js +129 -0
- package/src/posture/dead-code.js +430 -0
- package/src/posture/defender-agent.js +158 -0
- package/src/posture/deploy-platform.js +204 -0
- package/src/posture/detector-fuzz.js +61 -0
- package/src/posture/deterministic.js +99 -0
- package/src/posture/drift.js +165 -0
- package/src/posture/epss.js +156 -0
- package/src/posture/exploitability-probability.js +212 -0
- package/src/posture/exploitability.js +121 -0
- package/src/posture/feature-flags.js +110 -0
- package/src/posture/finding-defaults.js +132 -0
- package/src/posture/fix-history.js +411 -0
- package/src/posture/fix-plan.js +121 -0
- package/src/posture/fix-verify-loop.js +157 -0
- package/src/posture/fix-verify.js +130 -0
- package/src/posture/flow-narration.js +105 -0
- package/src/posture/grader-calibration.js +156 -0
- package/src/posture/harness-discovery.js +113 -0
- package/src/posture/holdout-eval.js +144 -0
- package/src/posture/iac-reachability.js +163 -0
- package/src/posture/iam-policy.js +128 -0
- package/src/posture/integrity.js +97 -0
- package/src/posture/learning.js +166 -0
- package/src/posture/license-policy.js +109 -0
- package/src/posture/llm-redteam-prompts.js +418 -0
- package/src/posture/llm-redteam.js +303 -0
- package/src/posture/material-change.js +163 -0
- package/src/posture/mitigation-composite.js +55 -0
- package/src/posture/mttr.js +91 -0
- package/src/posture/network-policy-import.js +126 -0
- package/src/posture/path-predicates.js +99 -0
- package/src/posture/persona-prioritization.js +153 -0
- package/src/posture/poc-cwe-map.js +51 -0
- package/src/posture/poc-generator.js +500 -0
- package/src/posture/policy-gate.js +174 -0
- package/src/posture/pre-incident-archaeology.js +110 -0
- package/src/posture/profile.js +93 -0
- package/src/posture/reachability-filter.js +42 -0
- package/src/posture/regression-test-gen.js +200 -0
- package/src/posture/reverse-blast-radius.js +110 -0
- package/src/posture/router.js +109 -0
- package/src/posture/rule-overrides.js +198 -0
- package/src/posture/rule-pack-signing.js +209 -0
- package/src/posture/rule-packs.js +143 -0
- package/src/posture/rule-synthesis.js +108 -0
- package/src/posture/ruleset-version.js +71 -0
- package/src/posture/sbom.js +129 -0
- package/src/posture/schema-aware-bridge.js +207 -0
- package/src/posture/security-trend.js +87 -0
- package/src/posture/semantic-clone.js +114 -0
- package/src/posture/specification-mining.js +170 -0
- package/src/posture/stable-id.js +75 -0
- package/src/posture/stack-playbook.js +229 -0
- package/src/posture/streak.js +249 -0
- package/src/posture/suppressions.js +135 -0
- package/src/posture/telemetry-ingest.js +112 -0
- package/src/posture/threat-model.js +145 -0
- package/src/posture/three-agent-pipeline.js +74 -0
- package/src/posture/triage.js +146 -0
- package/src/posture/trust-boundary-diagram.js +115 -0
- package/src/posture/type-narrowing.js +129 -0
- package/src/posture/validator-metrics.js +179 -0
- package/src/posture/verifier-ephemeral.js +118 -0
- package/src/posture/verifier-target.js +147 -0
- package/src/posture/verifier.js +257 -0
- package/src/posture/version.js +75 -0
- package/src/posture/waf-ingest.js +200 -0
- package/src/posture/why-fired.js +141 -0
- package/src/pr-comment.js +172 -0
- package/src/pr-delta.js +198 -0
- package/src/report/.agentic-security/findings.json +79 -0
- package/src/report/.agentic-security/last-scan.json +79 -0
- package/src/report/.agentic-security/last-scan.json.sig +1 -0
- package/src/report/.agentic-security/scan-history.json +332 -0
- package/src/report/.agentic-security/streak.json +23 -0
- package/src/report/index.js +1136 -0
- package/src/report/mascot.js +42 -0
- package/src/runScan.js +141 -0
- package/src/sast/.agentic-security/findings.json +5051 -0
- package/src/sast/.agentic-security/last-scan.json +5051 -0
- package/src/sast/.agentic-security/last-scan.json.sig +1 -0
- package/src/sast/.agentic-security/scan-history.json +788 -0
- package/src/sast/.agentic-security/streak.json +23 -0
- package/src/sast/CLAUDE.md +39 -0
- package/src/sast/_comment-strip.js +46 -0
- package/src/sast/agent-tool-escalation.js +131 -0
- package/src/sast/auth-provider.js +171 -0
- package/src/sast/authz.js +236 -0
- package/src/sast/bench-shape/.agentic-security/findings.json +28 -0
- package/src/sast/bench-shape/.agentic-security/last-scan.json +28 -0
- package/src/sast/bench-shape/.agentic-security/scan-history.json +24 -0
- package/src/sast/bench-shape/.agentic-security/streak.json +22 -0
- package/src/sast/bench-shape/index.js +62 -0
- package/src/sast/claude-hook-injection.js +199 -0
- package/src/sast/claude-md-prompt-injection.js +170 -0
- package/src/sast/claude-settings.js +165 -0
- package/src/sast/client-side.js +149 -0
- package/src/sast/cpp-bench-extras.js +122 -0
- package/src/sast/cpp-dataflow.js +430 -0
- package/src/sast/cpp.js +248 -0
- package/src/sast/csharp.js +152 -0
- package/src/sast/csrf.js +82 -0
- package/src/sast/dart-flutter.js +173 -0
- package/src/sast/db-rls.js +147 -0
- package/src/sast/db-taint.js +215 -0
- package/src/sast/defi-deep.js +242 -0
- package/src/sast/deserialization-gadgets.js +113 -0
- package/src/sast/django-hardening.js +230 -0
- package/src/sast/env-hygiene.js +125 -0
- package/src/sast/fastapi-hardening.js +145 -0
- package/src/sast/go-extended.js +84 -0
- package/src/sast/host-header.js +106 -0
- package/src/sast/index.js +17 -0
- package/src/sast/java-ast-folding.js +561 -0
- package/src/sast/java-bench-extras.js +708 -0
- package/src/sast/java-collection-passthrough.js +178 -0
- package/src/sast/java-constant-fold.js +244 -0
- package/src/sast/java-deserialization.js +125 -0
- package/src/sast/jndi.js +104 -0
- package/src/sast/juliet-shape.js +324 -0
- package/src/sast/jwt-exp.js +104 -0
- package/src/sast/kotlin.js +82 -0
- package/src/sast/laravel-hardening.js +198 -0
- package/src/sast/ldap-injection.js +100 -0
- package/src/sast/llm-owasp.js +465 -0
- package/src/sast/llm-stored-prompt.js +103 -0
- package/src/sast/llm-trading-agent.js +161 -0
- package/src/sast/llm.js +308 -0
- package/src/sast/logic.js +140 -0
- package/src/sast/mass-assignment.js +101 -0
- package/src/sast/mcp-audit.js +242 -0
- package/src/sast/mobile-manifest.js +195 -0
- package/src/sast/model-load.js +164 -0
- package/src/sast/mutation-xss.js +87 -0
- package/src/sast/nosql-injection.js +82 -0
- package/src/sast/open-redirect.js +119 -0
- package/src/sast/php.js +91 -0
- package/src/sast/pipeline.js +122 -0
- package/src/sast/primary-cwe-java.js +155 -0
- package/src/sast/prompt-firewall.js +151 -0
- package/src/sast/prompt-template.js +157 -0
- package/src/sast/prototype-pollution.js +112 -0
- package/src/sast/python-sinks.js +195 -0
- package/src/sast/quarkus-hardening.js +102 -0
- package/src/sast/rag-poisoning.js +118 -0
- package/src/sast/rate-limit.js +128 -0
- package/src/sast/response-splitting.js +138 -0
- package/src/sast/ruby.js +108 -0
- package/src/sast/rust.js +105 -0
- package/src/sast/solidity.js +167 -0
- package/src/sast/springboot-hardening.js +186 -0
- package/src/sast/ssrf-cloud-metadata.js +80 -0
- package/src/sast/ssti.js +116 -0
- package/src/sast/swift.js +162 -0
- package/src/sast/toctou.js +95 -0
- package/src/sast/webhook.js +101 -0
- package/src/sast/xpath-injection.js +51 -0
- package/src/sast/xxe.js +140 -0
- package/src/sast/zip-slip.js +200 -0
- package/src/sca/base-images.json +45 -0
- package/src/sca/container.js +107 -0
- package/src/sca/dep-confusion.js +134 -0
- package/src/sca/index.js +6 -0
- package/src/sca/popular-packages.json +41 -0
- package/src/sca/sarif-ingest.js +187 -0
- package/src/sca/vuln-function-hints.json +89 -0
- package/src/secrets/index.js +4 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// IaC → application code reachability bridge (Sentinel-parity FR-DET-4).
|
|
2
|
+
//
|
|
3
|
+
// Detects publicly-exposed cloud resources in IaC (Terraform / CloudFormation
|
|
4
|
+
// / Kubernetes) and correlates them with application-code references to the
|
|
5
|
+
// same resource (by name, ARN, or hostname). Application-code findings on
|
|
6
|
+
// resources that IaC has exposed get a severity bump and an explicit
|
|
7
|
+
// "exposed-via-iac" tag.
|
|
8
|
+
//
|
|
9
|
+
// Patterns detected:
|
|
10
|
+
//
|
|
11
|
+
// S3 bucket with public-read ACL / public-access-block disabled
|
|
12
|
+
// RDS / DocumentDB / Redshift with publicly_accessible = true
|
|
13
|
+
// Security group with 0.0.0.0/0 ingress on a sensitive port
|
|
14
|
+
// ALB / NLB / API Gateway with internet-facing scheme
|
|
15
|
+
// K8s Service of type LoadBalancer with no NetworkPolicy
|
|
16
|
+
// K8s Ingress with no auth annotation
|
|
17
|
+
// Lambda function URL with auth_type = NONE
|
|
18
|
+
// ECS task with assignPublicIp = ENABLED
|
|
19
|
+
//
|
|
20
|
+
// Output: { exposedResources: [{name, kind, file, line, severity}], findings: [...new findings] }
|
|
21
|
+
|
|
22
|
+
const SENSITIVE_PORTS = new Set([22, 23, 25, 110, 143, 3306, 3389, 5432, 6379, 27017, 9200, 9300, 1521, 5984, 11211]);
|
|
23
|
+
|
|
24
|
+
// Match Terraform resource blocks.
|
|
25
|
+
function parseTerraform(fileContents) {
|
|
26
|
+
const resources = [];
|
|
27
|
+
for (const [fp, c] of Object.entries(fileContents || {})) {
|
|
28
|
+
if (!/\.tf$/i.test(fp)) continue;
|
|
29
|
+
if (!c || typeof c !== 'string') continue;
|
|
30
|
+
// resource "TYPE" "NAME" { ... }
|
|
31
|
+
const re = /resource\s+"([^"]+)"\s+"([^"]+)"\s*\{([\s\S]*?)\n\}/g;
|
|
32
|
+
let m;
|
|
33
|
+
while ((m = re.exec(c))) {
|
|
34
|
+
const line = c.substring(0, m.index).split('\n').length;
|
|
35
|
+
resources.push({ file: fp, line, kind: m[1], name: m[2], body: m[3] });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return resources;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function classifyExposure(r) {
|
|
42
|
+
const reasons = [];
|
|
43
|
+
if (r.kind === 'aws_s3_bucket' || r.kind === 'aws_s3_bucket_acl' || r.kind === 'aws_s3_bucket_public_access_block') {
|
|
44
|
+
if (/acl\s*=\s*"public-read"|acl\s*=\s*"public-read-write"/.test(r.body)) reasons.push('s3-public-acl');
|
|
45
|
+
if (/block_public_acls\s*=\s*false|restrict_public_buckets\s*=\s*false/.test(r.body)) reasons.push('s3-public-access-block-off');
|
|
46
|
+
}
|
|
47
|
+
if (/aws_db_instance|aws_rds|aws_docdb_cluster_instance/.test(r.kind)) {
|
|
48
|
+
if (/publicly_accessible\s*=\s*true/.test(r.body)) reasons.push('db-publicly-accessible');
|
|
49
|
+
}
|
|
50
|
+
if (r.kind === 'aws_security_group' || r.kind === 'aws_security_group_rule' || r.kind === 'aws_vpc_security_group_ingress_rule') {
|
|
51
|
+
if (/cidr_blocks?\s*=\s*\[\s*"0\.0\.0\.0\/0"\s*\]|cidr_ipv4\s*=\s*"0\.0\.0\.0\/0"/.test(r.body)) {
|
|
52
|
+
const portMatch = r.body.match(/from_port\s*=\s*(\d+)/);
|
|
53
|
+
const port = portMatch ? parseInt(portMatch[1], 10) : null;
|
|
54
|
+
if (port && SENSITIVE_PORTS.has(port)) reasons.push(`sg-open-${port}`);
|
|
55
|
+
else if (port === 0 || (portMatch && parseInt(portMatch[1], 10) === 0)) reasons.push('sg-all-ports-open');
|
|
56
|
+
else if (!portMatch) reasons.push('sg-no-port-restriction');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (/aws_lb\b|aws_alb\b|aws_elb\b/.test(r.kind)) {
|
|
60
|
+
if (/internal\s*=\s*false/.test(r.body) || /scheme\s*=\s*"internet-facing"/.test(r.body)) reasons.push('lb-internet-facing');
|
|
61
|
+
}
|
|
62
|
+
if (r.kind === 'aws_lambda_function_url') {
|
|
63
|
+
if (/authorization_type\s*=\s*"NONE"/.test(r.body)) reasons.push('lambda-url-no-auth');
|
|
64
|
+
}
|
|
65
|
+
if (r.kind === 'aws_ecs_service' || r.kind === 'aws_ecs_task_definition') {
|
|
66
|
+
if (/assign_public_ip\s*=\s*true/.test(r.body)) reasons.push('ecs-public-ip');
|
|
67
|
+
}
|
|
68
|
+
return reasons;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Detect application-code references to a named resource. Heuristic:
|
|
72
|
+
// `process.env.<UPPERCASE_NAME>`, hardcoded `"<bucket-name>"` literals, or
|
|
73
|
+
// ARN substrings containing the resource name.
|
|
74
|
+
function findCodeReferences(fileContents, resourceName) {
|
|
75
|
+
const out = [];
|
|
76
|
+
const envName = resourceName.toUpperCase().replace(/[^A-Z0-9]/g, '_');
|
|
77
|
+
for (const [fp, c] of Object.entries(fileContents || {})) {
|
|
78
|
+
if (!c || typeof c !== 'string') continue;
|
|
79
|
+
if (c.length > 500_000) continue;
|
|
80
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py|rb|go|java|kt|cs|php)$/i.test(fp)) continue;
|
|
81
|
+
// process.env.<NAME> reference.
|
|
82
|
+
const re1 = new RegExp(`\\b(?:process\\.env|os\\.environ|os\\.getenv|System\\.getenv)\\s*[.\\[]\\s*['"]?${envName}['"]?`, 'g');
|
|
83
|
+
let m;
|
|
84
|
+
while ((m = re1.exec(c))) {
|
|
85
|
+
const line = c.substring(0, m.index).split('\n').length;
|
|
86
|
+
out.push({ file: fp, line, refType: 'env-var' });
|
|
87
|
+
}
|
|
88
|
+
// String literal reference.
|
|
89
|
+
const re2 = new RegExp(`['"]${resourceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`, 'g');
|
|
90
|
+
while ((m = re2.exec(c))) {
|
|
91
|
+
const line = c.substring(0, m.index).split('\n').length;
|
|
92
|
+
out.push({ file: fp, line, refType: 'literal' });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function scanIacReachability(fileContents, existingFindings) {
|
|
99
|
+
const resources = parseTerraform(fileContents);
|
|
100
|
+
if (resources.length === 0) return [];
|
|
101
|
+
const exposed = [];
|
|
102
|
+
for (const r of resources) {
|
|
103
|
+
const reasons = classifyExposure(r);
|
|
104
|
+
if (reasons.length) exposed.push({ ...r, reasons });
|
|
105
|
+
}
|
|
106
|
+
if (!exposed.length) return [];
|
|
107
|
+
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const r of exposed) {
|
|
110
|
+
// 1. Always emit an IaC finding for the exposed resource itself.
|
|
111
|
+
out.push({
|
|
112
|
+
id: `iac-exposed:${r.file}:${r.line}:${r.kind}:${r.name}`,
|
|
113
|
+
file: r.file, line: r.line,
|
|
114
|
+
vuln: `Publicly-exposed cloud resource (${r.kind} "${r.name}"): ${r.reasons.join(', ')}`,
|
|
115
|
+
severity: 'high',
|
|
116
|
+
cwe: 'CWE-668',
|
|
117
|
+
stride: 'Information Disclosure',
|
|
118
|
+
snippet: `${r.kind} "${r.name}"`,
|
|
119
|
+
remediation: `Tighten the IaC config for ${r.kind} "${r.name}". Specific reasons: ${r.reasons.join(', ')}. ` +
|
|
120
|
+
`Remove public-read ACLs / publicly_accessible flags / 0.0.0.0/0 ingress on sensitive ports. ` +
|
|
121
|
+
`Use private subnets + VPC endpoints; require IAM-authenticated access at minimum.`,
|
|
122
|
+
parser: 'IAC-REACH',
|
|
123
|
+
confidence: 0.85,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// 2. Find application-code references; bump severity on findings that
|
|
127
|
+
// sit on lines referencing this resource.
|
|
128
|
+
const refs = findCodeReferences(fileContents, r.name);
|
|
129
|
+
for (const ref of refs) {
|
|
130
|
+
// Look for existing findings near this ref line.
|
|
131
|
+
const nearby = (existingFindings || []).filter(f =>
|
|
132
|
+
f.file === ref.file && Math.abs((f.line || 0) - ref.line) <= 5
|
|
133
|
+
);
|
|
134
|
+
for (const f of nearby) {
|
|
135
|
+
// Bump severity by one notch and annotate.
|
|
136
|
+
const before = f.severity;
|
|
137
|
+
if (before === 'medium') f.severity = 'high';
|
|
138
|
+
else if (before === 'low') f.severity = 'medium';
|
|
139
|
+
f.iacExposed = true;
|
|
140
|
+
f.iacExposureReason = r.reasons.join(',');
|
|
141
|
+
f.iacResource = `${r.kind}:${r.name}`;
|
|
142
|
+
}
|
|
143
|
+
if (refs.length && !nearby.length) {
|
|
144
|
+
out.push({
|
|
145
|
+
id: `iac-codepath:${ref.file}:${ref.line}:${r.name}`,
|
|
146
|
+
file: ref.file, line: ref.line,
|
|
147
|
+
vuln: `Application references publicly-exposed ${r.kind} "${r.name}" (${r.reasons.join(', ')})`,
|
|
148
|
+
severity: 'medium',
|
|
149
|
+
cwe: 'CWE-668',
|
|
150
|
+
snippet: `(reference to ${r.kind}:${r.name})`,
|
|
151
|
+
remediation: `Application code at ${ref.file}:${ref.line} reads the resource "${r.name}" which IaC has exposed publicly (${r.reasons.join(', ')}). Either fix the IaC config or assume the data path is untrusted and add server-side authz.`,
|
|
152
|
+
parser: 'IAC-REACH',
|
|
153
|
+
confidence: 0.6,
|
|
154
|
+
chain: [
|
|
155
|
+
{ file: r.file, line: r.line, label: `IaC: ${r.kind} ${r.name} (${r.reasons[0]})` },
|
|
156
|
+
{ file: ref.file, line: ref.line, label: `code uses ${r.name}` },
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// IAM policy reachability (FR-XSAT-7).
|
|
2
|
+
//
|
|
3
|
+
// Parses AWS IAM policy JSON files (Terraform inline policies, raw policy
|
|
4
|
+
// documents, CDK stack output, attached-role JSON), correlates them with
|
|
5
|
+
// app code references (env vars, role ARNs, service names), and flags
|
|
6
|
+
// over-permissive grants — wildcards on dangerous actions, * resource on
|
|
7
|
+
// high-blast-radius services, or PassRole on broad targets.
|
|
8
|
+
//
|
|
9
|
+
// Honest scope for v1:
|
|
10
|
+
// - Static analysis of policy docs. No cloud-API calls.
|
|
11
|
+
// - Reachability proxy: a policy is "reachable" if its file is in the
|
|
12
|
+
// repo AND the policy's role name or ARN appears anywhere in app code
|
|
13
|
+
// (likely indicating the app assumes that role).
|
|
14
|
+
// - The over-permissive ruleset is curated, not exhaustive. Aim for high
|
|
15
|
+
// precision; recall improvements queued for v2.
|
|
16
|
+
|
|
17
|
+
const IAM_POLICY_FILE_RE = /(?:^|\/)(?:.*\.iam\.json|policy.*\.json|iam-policy.*\.json|role-policy.*\.json)$/i;
|
|
18
|
+
const POLICY_JSON_HINTS = /"Version"\s*:\s*"2012-10-17"|"Statement"\s*:\s*\[/;
|
|
19
|
+
|
|
20
|
+
// Actions we consider dangerous when combined with wildcard resource OR
|
|
21
|
+
// effect=Allow + no Condition. Curated; not exhaustive.
|
|
22
|
+
const DANGEROUS_ACTIONS = [
|
|
23
|
+
// Identity/access — escalation primitives.
|
|
24
|
+
{ action: /^iam:(\*|PassRole|CreateRole|AttachRolePolicy|PutRolePolicy|UpdateAssumeRolePolicy)$/, family: 'iam-overpermissive', severity: 'critical', why: 'IAM action allowing role escalation' },
|
|
25
|
+
// Object storage — exfiltration.
|
|
26
|
+
{ action: /^s3:(\*|GetObject|PutObject|DeleteObject)$/, family: 'iam-overpermissive', severity: 'high', why: 'S3 read/write/delete' },
|
|
27
|
+
// Compute — lateral movement.
|
|
28
|
+
{ action: /^lambda:(\*|InvokeFunction|UpdateFunctionCode|CreateFunction)$/, family: 'iam-overpermissive', severity: 'high', why: 'Lambda action allowing code injection / lateral move' },
|
|
29
|
+
{ action: /^ec2:(\*|RunInstances|TerminateInstances|ModifyInstanceAttribute)$/, family: 'iam-overpermissive', severity: 'high', why: 'EC2 lifecycle control' },
|
|
30
|
+
// Data layer.
|
|
31
|
+
{ action: /^dynamodb:(\*|GetItem|PutItem|DeleteItem|Query|Scan)$/, family: 'iam-overpermissive', severity: 'medium', why: 'DynamoDB data access' },
|
|
32
|
+
{ action: /^rds:(\*|DeleteDBInstance|ModifyDBInstance)$/, family: 'iam-overpermissive', severity: 'high', why: 'RDS lifecycle control' },
|
|
33
|
+
// Secrets.
|
|
34
|
+
{ action: /^secretsmanager:(\*|GetSecretValue|PutSecretValue)$/, family: 'iam-overpermissive', severity: 'high', why: 'Secrets Manager read/write' },
|
|
35
|
+
{ action: /^kms:(\*|Decrypt|Encrypt|GenerateDataKey)$/, family: 'iam-overpermissive', severity: 'high', why: 'KMS key usage' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const WILDCARD_RESOURCE_RE = /^(\*|arn:[\w-]+:\w+:\*:\*:\*)$/;
|
|
39
|
+
|
|
40
|
+
function _parseStatements(raw) {
|
|
41
|
+
let policy;
|
|
42
|
+
try { policy = JSON.parse(raw); } catch { return []; }
|
|
43
|
+
const stmt = policy.Statement || (policy.PolicyDocument && policy.PolicyDocument.Statement);
|
|
44
|
+
if (!stmt) return [];
|
|
45
|
+
return Array.isArray(stmt) ? stmt : [stmt];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _lineOf(raw, sub) {
|
|
49
|
+
const idx = raw.indexOf(sub);
|
|
50
|
+
if (idx < 0) return 1;
|
|
51
|
+
return raw.substring(0, idx).split('\n').length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _expandActions(actions) {
|
|
55
|
+
if (!actions) return [];
|
|
56
|
+
return Array.isArray(actions) ? actions : [actions];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _expandResources(resources) {
|
|
60
|
+
if (!resources) return ['*'];
|
|
61
|
+
return Array.isArray(resources) ? resources : [resources];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _isWildcardResource(res) {
|
|
65
|
+
if (typeof res !== 'string') return false;
|
|
66
|
+
return WILDCARD_RESOURCE_RE.test(res);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Scan one IAM policy file. Returns finding objects.
|
|
71
|
+
*/
|
|
72
|
+
function scanOnePolicyFile(file, raw) {
|
|
73
|
+
if (!POLICY_JSON_HINTS.test(raw)) return [];
|
|
74
|
+
const statements = _parseStatements(raw);
|
|
75
|
+
const findings = [];
|
|
76
|
+
for (const s of statements) {
|
|
77
|
+
if ((s.Effect || 'Allow') !== 'Allow') continue;
|
|
78
|
+
const actions = _expandActions(s.Action);
|
|
79
|
+
const resources = _expandResources(s.Resource);
|
|
80
|
+
const hasCondition = s.Condition && Object.keys(s.Condition).length > 0;
|
|
81
|
+
// Identify which actions are dangerous + paired with wildcard resource
|
|
82
|
+
// OR no Condition gate.
|
|
83
|
+
for (const a of actions) {
|
|
84
|
+
const rule = DANGEROUS_ACTIONS.find(r => r.action.test(a));
|
|
85
|
+
if (!rule) continue;
|
|
86
|
+
const broadResource = resources.some(_isWildcardResource);
|
|
87
|
+
if (!broadResource && hasCondition) continue; // narrow + conditioned → ok
|
|
88
|
+
const reason = broadResource
|
|
89
|
+
? `wildcard resource ${resources.join(',')}`
|
|
90
|
+
: 'no Condition gate';
|
|
91
|
+
findings.push({
|
|
92
|
+
id: `iam:${file}:${a}:${reason}`,
|
|
93
|
+
file,
|
|
94
|
+
line: _lineOf(raw, `"${a}"`),
|
|
95
|
+
vuln: `IAM over-permissive grant: ${a} (${reason})`,
|
|
96
|
+
severity: broadResource ? rule.severity : 'medium',
|
|
97
|
+
cwe: 'CWE-732', // insecure resource permissions
|
|
98
|
+
family: rule.family,
|
|
99
|
+
stride: 'Elevation of Privilege',
|
|
100
|
+
parser: 'IAM-POLICY',
|
|
101
|
+
confidence: 0.7,
|
|
102
|
+
snippet: `Effect=Allow Action=${a} Resource=${resources.join(',')}${hasCondition ? ' [Condition gate present]' : ''}`,
|
|
103
|
+
remediation: `${rule.why}. Either narrow the Action to the specific verb you need, scope Resource to a single ARN, or add a Condition (e.g. \`aws:PrincipalTag\`, \`aws:SourceIp\`, \`aws:RequestedRegion\`) so the grant isn't usable from arbitrary contexts.`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return findings;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Module entry point. fileContents is the full project map; existingFindings
|
|
112
|
+
* is the engine's findings list (used for reachability correlation — the
|
|
113
|
+
* future Phase-2.5 expansion).
|
|
114
|
+
*/
|
|
115
|
+
export function scanIamPolicies(fileContents /* , existingFindings */) {
|
|
116
|
+
if (!fileContents || typeof fileContents !== 'object') return [];
|
|
117
|
+
const findings = [];
|
|
118
|
+
for (const [fp, content] of Object.entries(fileContents)) {
|
|
119
|
+
if (typeof content !== 'string' || content.length === 0) continue;
|
|
120
|
+
if (content.length > 500_000) continue;
|
|
121
|
+
if (!IAM_POLICY_FILE_RE.test(fp) && !POLICY_JSON_HINTS.test(content.slice(0, 500))) continue;
|
|
122
|
+
findings.push(...scanOnePolicyFile(fp, content));
|
|
123
|
+
}
|
|
124
|
+
return findings;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// For tests + the no-dead-modules check.
|
|
128
|
+
export const _internals = { DANGEROUS_ACTIONS, IAM_POLICY_FILE_RE, scanOnePolicyFile };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Tamper-evidence for `.agentic-security/last-scan.json`.
|
|
2
|
+
//
|
|
3
|
+
// Writes a sibling `.sig` file containing an HMAC-SHA256 of the JSON body.
|
|
4
|
+
// Readers verify the signature before trusting findings counts / file paths.
|
|
5
|
+
//
|
|
6
|
+
// KEY MATERIAL (premortem #1):
|
|
7
|
+
// The key is read from one of:
|
|
8
|
+
// 1. $AGENTIC_SECURITY_HMAC_KEY — explicit operator-provided key (hex)
|
|
9
|
+
// 2. $XDG_CONFIG_HOME/agentic-security/scan-key (or ~/.config/agentic-security/scan-key)
|
|
10
|
+
// — a per-install 32-byte random key, mode 0600, generated on first use.
|
|
11
|
+
// The old hostname-derived key is accepted in VERIFY-ONLY mode for one
|
|
12
|
+
// release so existing signed `last-scan.json` files keep verifying. New
|
|
13
|
+
// signatures only use the random key.
|
|
14
|
+
//
|
|
15
|
+
// Threat model: this is a guardrail against accidental corruption, naive
|
|
16
|
+
// manual edits, CI-cache poisoning, and supply-chain planting of a fake
|
|
17
|
+
// last-scan.json designed to weaponize MCP `apply_fix`. An attacker who
|
|
18
|
+
// reads $AGENTIC_SECURITY_HMAC_KEY or the on-disk key file can forge — so
|
|
19
|
+
// the key file is mode 0600, and the env-var variant is intended for
|
|
20
|
+
// operators who manage secrets separately (Doppler/Infisical/etc.).
|
|
21
|
+
|
|
22
|
+
import * as crypto from 'node:crypto';
|
|
23
|
+
import * as fs from 'node:fs';
|
|
24
|
+
import * as os from 'node:os';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
|
|
27
|
+
const _HMAC_SALT = 'agentic-security:last-scan:v1';
|
|
28
|
+
|
|
29
|
+
function _keyDir() {
|
|
30
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
31
|
+
const base = xdg && xdg.length ? xdg : path.join(os.homedir(), '.config');
|
|
32
|
+
return path.join(base, 'agentic-security');
|
|
33
|
+
}
|
|
34
|
+
function _keyPath() { return path.join(_keyDir(), 'scan-key'); }
|
|
35
|
+
|
|
36
|
+
function _readOrGenerateKey() {
|
|
37
|
+
const fromEnv = process.env.AGENTIC_SECURITY_HMAC_KEY;
|
|
38
|
+
if (fromEnv && /^[0-9a-fA-F]{32,}$/.test(fromEnv.trim())) {
|
|
39
|
+
return Buffer.from(fromEnv.trim(), 'hex');
|
|
40
|
+
}
|
|
41
|
+
const fp = _keyPath();
|
|
42
|
+
try {
|
|
43
|
+
if (fs.existsSync(fp)) {
|
|
44
|
+
const hex = fs.readFileSync(fp, 'utf8').trim();
|
|
45
|
+
if (/^[0-9a-fA-F]{32,}$/.test(hex)) return Buffer.from(hex, 'hex');
|
|
46
|
+
}
|
|
47
|
+
} catch { /* fall through to generate */ }
|
|
48
|
+
// Generate, mode 0600.
|
|
49
|
+
const buf = crypto.randomBytes(32);
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(_keyDir(), { recursive: true, mode: 0o700 });
|
|
52
|
+
fs.writeFileSync(fp, buf.toString('hex') + '\n', { mode: 0o600 });
|
|
53
|
+
} catch { /* best-effort — fall back to in-memory key for the process */ }
|
|
54
|
+
return buf;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function _legacyHostnameKey() {
|
|
58
|
+
return crypto.createHash('sha256').update(`${_HMAC_SALT}:${os.hostname()}`).digest();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let _cachedKey = null;
|
|
62
|
+
function _key() {
|
|
63
|
+
if (_cachedKey) return _cachedKey;
|
|
64
|
+
_cachedKey = _readOrGenerateKey();
|
|
65
|
+
return _cachedKey;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function signLastScan(body) {
|
|
69
|
+
return crypto.createHmac('sha256', _key()).update(body).digest('hex');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Verify body against a sibling .sig file.
|
|
73
|
+
// Returns true if valid under the current install key OR the legacy hostname
|
|
74
|
+
// key (for one-release migration), false if invalid, null if sig file is
|
|
75
|
+
// absent (first-run case — call sites decide whether absent == fail-closed).
|
|
76
|
+
export function verifyLastScan(body, sigFile) {
|
|
77
|
+
if (!fs.existsSync(sigFile)) return null;
|
|
78
|
+
let stored;
|
|
79
|
+
try { stored = fs.readFileSync(sigFile, 'utf8').trim(); }
|
|
80
|
+
catch { return false; }
|
|
81
|
+
const tryKey = (k) => {
|
|
82
|
+
try {
|
|
83
|
+
const expected = crypto.createHmac('sha256', k).update(body).digest('hex');
|
|
84
|
+
if (stored.length !== expected.length) return false;
|
|
85
|
+
return crypto.timingSafeEqual(Buffer.from(stored, 'hex'), Buffer.from(expected, 'hex'));
|
|
86
|
+
} catch { return false; }
|
|
87
|
+
};
|
|
88
|
+
if (tryKey(_key())) return true;
|
|
89
|
+
// Legacy hostname-key path — accepted for verification only, not for new
|
|
90
|
+
// signatures. Remove after one minor release.
|
|
91
|
+
if (tryKey(_legacyHostnameKey())) return true;
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Test-only helpers (premortem-tracked):
|
|
96
|
+
export function _resetKeyCacheForTests() { _cachedKey = null; }
|
|
97
|
+
export function _keyFilePathForTests() { return _keyPath(); }
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Active learning loop (FR-PREC-4).
|
|
2
|
+
//
|
|
3
|
+
// Consumes user triage decisions written by /triage to
|
|
4
|
+
// .agentic-security/triage-feedback.json and uses them as priors on the
|
|
5
|
+
// next scan:
|
|
6
|
+
// - Findings whose `stableId` was previously marked false-positive get
|
|
7
|
+
// suppressed (with a recorded reason).
|
|
8
|
+
// - Findings whose `family + file-pattern + sink-pattern` matches a FP
|
|
9
|
+
// pattern in past feedback also get suppressed.
|
|
10
|
+
// - Findings whose stableId was previously marked true-positive get a
|
|
11
|
+
// small confidence boost.
|
|
12
|
+
//
|
|
13
|
+
// File shape:
|
|
14
|
+
// {
|
|
15
|
+
// "entries": [
|
|
16
|
+
// { "stableId": "...", "verdict": "tp" | "fp" | "wontfix", "reason": "...",
|
|
17
|
+
// "family": "...", "filePattern": "src/auth/*.js", "sinkPattern": "...",
|
|
18
|
+
// "at": "2026-05-18T..." }
|
|
19
|
+
// ]
|
|
20
|
+
// }
|
|
21
|
+
|
|
22
|
+
import * as fs from 'node:fs';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
|
|
25
|
+
const FILE = '.agentic-security/triage-feedback.json';
|
|
26
|
+
|
|
27
|
+
export function loadFeedback(scanRoot) {
|
|
28
|
+
if (!scanRoot) return { entries: [] };
|
|
29
|
+
const fp = path.join(scanRoot, FILE);
|
|
30
|
+
if (!fs.existsSync(fp)) return { entries: [] };
|
|
31
|
+
try { return JSON.parse(fs.readFileSync(fp, 'utf8')) || { entries: [] }; }
|
|
32
|
+
catch { return { entries: [] }; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function saveFeedback(scanRoot, data) {
|
|
36
|
+
if (!scanRoot) return;
|
|
37
|
+
const fp = path.join(scanRoot, FILE);
|
|
38
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
39
|
+
fs.writeFileSync(fp, JSON.stringify(data, null, 2));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function recordVerdict(scanRoot, finding, verdict, reason) {
|
|
43
|
+
if (!['tp', 'fp', 'wontfix'].includes(verdict)) {
|
|
44
|
+
throw new Error(`invalid verdict: ${verdict}`);
|
|
45
|
+
}
|
|
46
|
+
const data = loadFeedback(scanRoot);
|
|
47
|
+
data.entries = data.entries || [];
|
|
48
|
+
data.entries.push({
|
|
49
|
+
stableId: finding.stableId || null,
|
|
50
|
+
verdict,
|
|
51
|
+
reason: reason || '',
|
|
52
|
+
family: finding.family || null,
|
|
53
|
+
file: finding.file || null,
|
|
54
|
+
line: finding.line || null,
|
|
55
|
+
vuln: finding.vuln || null,
|
|
56
|
+
sinkSnippet: (finding.sink?.snippet || finding.snippet || '').slice(0, 200),
|
|
57
|
+
at: new Date().toISOString(),
|
|
58
|
+
});
|
|
59
|
+
saveFeedback(scanRoot, data);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function matchesPattern(filePath, pattern) {
|
|
63
|
+
if (!pattern) return true;
|
|
64
|
+
// Very small glob — supports `*` and `**` only; sufficient for triage entries.
|
|
65
|
+
const regex = new RegExp(
|
|
66
|
+
'^' +
|
|
67
|
+
pattern
|
|
68
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
69
|
+
.replace(/\*\*/g, '@@DOUBLE@@')
|
|
70
|
+
.replace(/\*/g, '[^/]*')
|
|
71
|
+
.replace(/@@DOUBLE@@/g, '.*') +
|
|
72
|
+
'$'
|
|
73
|
+
);
|
|
74
|
+
return regex.test(filePath);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Apply feedback to a freshly produced set of findings. Returns
|
|
78
|
+
// { kept: Finding[], suppressed: SuppressionLogEntry[] }.
|
|
79
|
+
//
|
|
80
|
+
// SAFETY (post-premortem R3.3):
|
|
81
|
+
// - DEFAULT OFF: only consulted when AGENTIC_SECURITY_LEARN=1.
|
|
82
|
+
// - QUORUM: a stableId is suppressed only after ≥ AGENTIC_SECURITY_LEARN_QUORUM
|
|
83
|
+
// distinct fp entries (default 2).
|
|
84
|
+
// - CAP: most-recent 500 entries by `at` timestamp are honored — bounds
|
|
85
|
+
// the poisoning blast radius.
|
|
86
|
+
// - PATTERN MATCHES require ≥ quorum entries with the same family+filePattern.
|
|
87
|
+
export function applyFeedback(scanRoot, findings) {
|
|
88
|
+
const suppressed = [];
|
|
89
|
+
// OPT-IN gate.
|
|
90
|
+
if (process.env.AGENTIC_SECURITY_LEARN !== '1') return { kept: findings, suppressed };
|
|
91
|
+
const data = loadFeedback(scanRoot);
|
|
92
|
+
if (!data.entries || !data.entries.length) return { kept: findings, suppressed };
|
|
93
|
+
const quorum = Math.max(1, parseInt(process.env.AGENTIC_SECURITY_LEARN_QUORUM || '2', 10));
|
|
94
|
+
// Keep the most recent 500 entries by `at`.
|
|
95
|
+
const sorted = [...data.entries].sort((a, b) => String(a.at || '').localeCompare(String(b.at || ''))).slice(-500);
|
|
96
|
+
const fpCountById = new Map();
|
|
97
|
+
const fpById = new Map();
|
|
98
|
+
const tpById = new Set();
|
|
99
|
+
const patternCounts = new Map();
|
|
100
|
+
for (const e of sorted) {
|
|
101
|
+
if (e.verdict === 'fp') {
|
|
102
|
+
if (e.stableId) {
|
|
103
|
+
fpCountById.set(e.stableId, (fpCountById.get(e.stableId) || 0) + 1);
|
|
104
|
+
if (!fpById.has(e.stableId)) fpById.set(e.stableId, e);
|
|
105
|
+
}
|
|
106
|
+
if (e.family && (e.file || e.sinkSnippet)) {
|
|
107
|
+
const k = `${e.family}|${e.file ? e.file.split('/').slice(0, -1).join('/') + '/*' : ''}|${(e.sinkSnippet || '').slice(0, 80)}`;
|
|
108
|
+
patternCounts.set(k, (patternCounts.get(k) || 0) + 1);
|
|
109
|
+
}
|
|
110
|
+
} else if (e.verdict === 'tp') {
|
|
111
|
+
if (e.stableId) tpById.add(e.stableId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Drop stableIds below quorum.
|
|
115
|
+
for (const [id, count] of fpCountById) {
|
|
116
|
+
if (count < quorum) fpById.delete(id);
|
|
117
|
+
}
|
|
118
|
+
// Build pattern list — only patterns at quorum.
|
|
119
|
+
const fpPatterns = [];
|
|
120
|
+
for (const [key, count] of patternCounts) {
|
|
121
|
+
if (count < quorum) continue;
|
|
122
|
+
const [family, filePattern, sinkSnippet] = key.split('|');
|
|
123
|
+
fpPatterns.push({
|
|
124
|
+
family: family || null,
|
|
125
|
+
filePattern: filePattern || null,
|
|
126
|
+
sinkSnippet: sinkSnippet || '',
|
|
127
|
+
reason: `quorum-${count}-fp-votes`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const kept = [];
|
|
131
|
+
for (const f of findings) {
|
|
132
|
+
if (!f) continue;
|
|
133
|
+
// Direct stableId match → suppress
|
|
134
|
+
if (f.stableId && fpById.has(f.stableId)) {
|
|
135
|
+
suppressed.push({
|
|
136
|
+
vuln: f.vuln, file: f.file, line: f.line, snippet: f.snippet,
|
|
137
|
+
reason: 'learned-fp:' + (fpById.get(f.stableId).reason || 'past-triage'),
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// Pattern match
|
|
142
|
+
let patternHit = null;
|
|
143
|
+
for (const p of fpPatterns) {
|
|
144
|
+
if (p.family && f.family !== p.family) continue;
|
|
145
|
+
if (p.filePattern && !matchesPattern(f.file || '', p.filePattern)) continue;
|
|
146
|
+
const sinkText = (f.sink?.snippet || f.snippet || '').slice(0, 80);
|
|
147
|
+
if (p.sinkSnippet && sinkText.includes(p.sinkSnippet.slice(0, 30))) {
|
|
148
|
+
patternHit = p; break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (patternHit) {
|
|
152
|
+
suppressed.push({
|
|
153
|
+
vuln: f.vuln, file: f.file, line: f.line, snippet: f.snippet,
|
|
154
|
+
reason: 'learned-fp-pattern:' + (patternHit.reason || 'past-triage'),
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// TP boost — small confidence bump.
|
|
159
|
+
if (f.stableId && tpById.has(f.stableId)) {
|
|
160
|
+
f.confidence = Math.min(1, (f.confidence || 0.5) + 0.10);
|
|
161
|
+
f._learned = 'tp-prior';
|
|
162
|
+
}
|
|
163
|
+
kept.push(f);
|
|
164
|
+
}
|
|
165
|
+
return { kept, suppressed };
|
|
166
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// 0.8.0 Feat-10: License policy enforcement — allow/deny/review per SPDX license expression.
|
|
2
|
+
//
|
|
3
|
+
// Reads .agentic-security/license-policy.yml (allow / deny / review-required) and
|
|
4
|
+
// emits findings of kind 'license' for components whose license violates the policy.
|
|
5
|
+
//
|
|
6
|
+
// Policy file shape:
|
|
7
|
+
// allow: ['MIT', 'Apache-2.0', 'BSD-3-Clause', 'BSD-2-Clause', 'ISC', '0BSD']
|
|
8
|
+
// deny: ['GPL-3.0', 'GPL-2.0', 'AGPL-3.0', 'AGPL-1.0', 'SSPL-1.0']
|
|
9
|
+
// review: ['LGPL-2.1', 'LGPL-3.0', 'MPL-2.0']
|
|
10
|
+
// unknown: 'review' # 'deny' | 'allow' | 'review' — what to do with components missing a license
|
|
11
|
+
//
|
|
12
|
+
// Default policy (when no file exists) is permissive: nothing fires unless the
|
|
13
|
+
// user opts in by creating the policy file.
|
|
14
|
+
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import * as yaml from 'js-yaml';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_POLICY = {
|
|
20
|
+
allow: [],
|
|
21
|
+
deny: [],
|
|
22
|
+
review: [],
|
|
23
|
+
unknown: 'allow',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function loadLicensePolicy(scanRoot) {
|
|
27
|
+
if (!scanRoot) return null;
|
|
28
|
+
for (const name of ['license-policy.yml', 'license-policy.yaml', 'license-policy.json']) {
|
|
29
|
+
const p = path.join(scanRoot, '.agentic-security', name);
|
|
30
|
+
if (!fs.existsSync(p)) continue;
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
33
|
+
const doc = name.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
|
|
34
|
+
return _normalize(doc);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return { _error: `Failed to parse ${p}: ${e.message}` };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _normalize(doc) {
|
|
43
|
+
return {
|
|
44
|
+
allow: Array.isArray(doc?.allow) ? doc.allow.map(_norm) : [],
|
|
45
|
+
deny: Array.isArray(doc?.deny) ? doc.deny.map(_norm) : [],
|
|
46
|
+
review: Array.isArray(doc?.review) ? doc.review.map(_norm) : [],
|
|
47
|
+
unknown: ['allow','deny','review'].includes(doc?.unknown) ? doc.unknown : 'allow',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _norm(s) { return String(s||'').trim().toUpperCase(); }
|
|
52
|
+
|
|
53
|
+
// Classify a single license string against the policy.
|
|
54
|
+
// Returns one of: 'allow' | 'deny' | 'review' | 'unknown'.
|
|
55
|
+
export function classifyLicense(license, policy) {
|
|
56
|
+
policy = policy || DEFAULT_POLICY;
|
|
57
|
+
if (!license || !license.trim()) return policy.unknown || 'allow';
|
|
58
|
+
const norm = _norm(license);
|
|
59
|
+
// SPDX expressions can be compound: "(MIT OR Apache-2.0)"; treat as deny if ANY
|
|
60
|
+
// compound atom is denied; otherwise allow if any allowed atom matches.
|
|
61
|
+
const atoms = norm.replace(/[()]/g, '').split(/\s+(?:OR|AND)\s+/i).map(s=>s.trim()).filter(Boolean);
|
|
62
|
+
if (atoms.length > 1) {
|
|
63
|
+
if (atoms.some(a => policy.deny.includes(a))) return 'deny';
|
|
64
|
+
if (atoms.some(a => policy.review.includes(a))) return 'review';
|
|
65
|
+
if (atoms.some(a => policy.allow.includes(a))) return 'allow';
|
|
66
|
+
return policy.unknown || 'allow';
|
|
67
|
+
}
|
|
68
|
+
if (policy.deny.includes(norm)) return 'deny';
|
|
69
|
+
if (policy.review.includes(norm)) return 'review';
|
|
70
|
+
if (policy.allow.includes(norm)) return 'allow';
|
|
71
|
+
return policy.unknown || 'allow';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Run the policy against scan.components and emit findings of kind 'license'.
|
|
75
|
+
export function evaluateLicensePolicy(components, policy) {
|
|
76
|
+
if (!policy) return [];
|
|
77
|
+
if (policy._error) return [{
|
|
78
|
+
id: 'license-policy:error',
|
|
79
|
+
kind: 'license', severity: 'low',
|
|
80
|
+
vuln: 'License policy file failed to parse',
|
|
81
|
+
file: '.agentic-security/license-policy.yml', line: 0,
|
|
82
|
+
snippet: policy._error,
|
|
83
|
+
}];
|
|
84
|
+
const findings = [];
|
|
85
|
+
for (const c of components || []) {
|
|
86
|
+
const verdict = classifyLicense(c.license || '', policy);
|
|
87
|
+
if (verdict === 'allow') continue;
|
|
88
|
+
const sev = verdict === 'deny' ? 'high' : 'low';
|
|
89
|
+
const lic = c.license || '(none)';
|
|
90
|
+
findings.push({
|
|
91
|
+
id: `license-policy:${c.ecosystem}:${c.name}@${c.version}:${verdict}`,
|
|
92
|
+
kind: 'license', severity: sev,
|
|
93
|
+
vuln: verdict === 'deny'
|
|
94
|
+
? `Denied license: ${lic} in ${c.name}@${c.version}`
|
|
95
|
+
: verdict === 'review'
|
|
96
|
+
? `License requires review: ${lic} in ${c.name}@${c.version}`
|
|
97
|
+
: `Component without declared license: ${c.name}@${c.version}`,
|
|
98
|
+
file: c.filePath || 'package.json', line: 0,
|
|
99
|
+
snippet: `${c.ecosystem}:${c.name}@${c.version} — license ${lic}`,
|
|
100
|
+
fix: verdict === 'deny'
|
|
101
|
+
? 'Replace this dependency with a license-compatible alternative, or move it under .agentic-security/license-policy.yml `review:` if your legal team approves a one-off exception.'
|
|
102
|
+
: verdict === 'review'
|
|
103
|
+
? 'Have legal/license review confirm this license is compatible with your distribution model. Add it to `allow:` or `deny:` once decided.'
|
|
104
|
+
: 'Confirm this dependency\'s actual license (check the upstream repo). Many `unknown` cases are misconfigured registry metadata for an otherwise-permissive license.',
|
|
105
|
+
package: c.name, version: c.version, ecosystem: c.ecosystem, license: c.license || null,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return findings;
|
|
109
|
+
}
|