@clear-capabilities/agentic-security-scanner 0.157.2 → 0.158.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 +20 -0
- package/dist/3531.index.js +1 -1
- package/dist/4171.index.js +2 -2
- package/dist/8551.index.js +19 -4
- package/dist/agentic-security.mjs +1 -1
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/dataflow/engine.js +23 -0
- package/src/engine.js +1 -0
- package/src/language/CLAUDE.md +6 -1
- package/src/language/haskell-advisory-feed.js +179 -0
- package/src/language/haskell-models.js +8 -2
- package/src/language/haskell-sca.js +18 -4
- package/src/language/haskell-security-rules.js +1 -1
- package/src/language/haskell-supply.js +28 -0
- package/src/language/haskell-web.js +29 -1
- package/src/language/nix-sca.js +9 -3
- package/src/language/nixos-hardening.js +37 -2
- package/src/language/nixos-module-resolver.js +26 -1
- package/src/language/nixos-option-catalog.js +1 -0
- package/src/language/resolved-pass.js +9 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
18b81da52b654c93c6240dfc2909e049da0accb60cd1b2a8dca439d05cca7fd9 agentic-security.mjs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clear-capabilities/agentic-security-scanner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.158.0",
|
|
4
4
|
"description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -79,9 +79,9 @@
|
|
|
79
79
|
"test:lineage": "node --test test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js",
|
|
80
80
|
"test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
|
|
81
81
|
"test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
|
|
82
|
-
"test:haskell": "node --test test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js",
|
|
83
|
-
"test:nix": "node --test test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js",
|
|
84
|
-
"test:language": "node --test test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/language-gap-fixes.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
|
|
82
|
+
"test:haskell": "node --test test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js",
|
|
83
|
+
"test:nix": "node --test test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js",
|
|
84
|
+
"test:language": "node --test test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
|
|
85
85
|
"test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
|
|
86
86
|
"test:language-tools": "node --test test/haskell/haskell-web-auth.test.js test/haskell/haskell-support-gate.test.js",
|
|
87
87
|
"bench:language-support:check": "node ../bench/language-support/check.mjs",
|
package/src/dataflow/engine.js
CHANGED
|
@@ -1148,6 +1148,7 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
1148
1148
|
// Haskell: two sinks of the same kind on one line (`if b then f (clean x) else f x`) are distinct
|
|
1149
1149
|
// sites; without a site key they share an id and one silently replaces the other.
|
|
1150
1150
|
...(/\.(?:hs|lhs)$/i.test(_currentFile || '') ? { siteKey: _siteKey(taintedArgExpr) } : {}),
|
|
1151
|
+
...(/\.(?:hs|lhs)$/i.test(_currentFile || '') && taintedArgExpr && taintedArgExpr.kind === 'call' ? { _argCallee: taintedArgExpr.callee } : {}),
|
|
1151
1152
|
kind: 'taint',
|
|
1152
1153
|
sinkId: e.id,
|
|
1153
1154
|
vuln: e.vuln?.name || 'Tainted Sink',
|
|
@@ -1783,6 +1784,26 @@ function step(node, stateIn, callContext) {
|
|
|
1783
1784
|
// state, never into the SummaryCache key (that stays exactly what the caller
|
|
1784
1785
|
// supplied). Returns the ORIGINAL Set unchanged when there's nothing to add,
|
|
1785
1786
|
// so callers that never touch annotation-shaped params pay zero extra cost.
|
|
1787
|
+
// Haskell: a sink whose tainted argument IS a call to another sink of the same CWE on the same line is one flaw reported twice
|
|
1788
|
+
// (`readCreateProcess (shell cmd)`: `shell` builds the command, `readCreateProcess` runs it). The INNER sink is kept, because it is
|
|
1789
|
+
// where the tainted text enters the command; the outer one is recorded on it, never dropped silently. Only a nested pair collapses:
|
|
1790
|
+
// two independent sinks on one line (`if b then f (clean x) else f x`) have different arguments and stay two findings, so a
|
|
1791
|
+
// discharged sink still cannot absorb its unprotected sibling.
|
|
1792
|
+
function _collapseNestedHsSinks(all) {
|
|
1793
|
+
const base = (c) => String(c && typeof c === 'object' ? (c.name || c.text || '') : (c ?? '')).split('.').pop();
|
|
1794
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
1795
|
+
const f = all[i];
|
|
1796
|
+
if (f._argCallee === undefined || !/\.(?:hs|lhs)$/i.test(f.file || '')) continue;
|
|
1797
|
+
const want = base(f._argCallee);
|
|
1798
|
+
const inner = want && all.find((g) => g !== f && g.file === f.file && g.line === f.line && g.cwe === f.cwe && g.callee !== undefined && base(g.callee) === want);
|
|
1799
|
+
if (!inner) continue;
|
|
1800
|
+
inner.dedupedVulns = [...new Set([...(inner.dedupedVulns || []), f.vuln])];
|
|
1801
|
+
inner.alsoSink = [...new Set([...(inner.alsoSink || []), base(f.callee)])];
|
|
1802
|
+
all.splice(i, 1);
|
|
1803
|
+
}
|
|
1804
|
+
for (const f of all) if (f._argCallee !== undefined) delete f._argCallee;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1786
1807
|
// Short stable fingerprint of an argument expression (ignores line numbers and IR bookkeeping).
|
|
1787
1808
|
function _siteKey(expr) {
|
|
1788
1809
|
let h = 5381;
|
|
@@ -2575,6 +2596,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2575
2596
|
if (dg[f.severity]) f.severity = dg[f.severity];
|
|
2576
2597
|
}
|
|
2577
2598
|
Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
|
|
2599
|
+
_collapseNestedHsSinks(all);
|
|
2578
2600
|
return all;
|
|
2579
2601
|
|
|
2580
2602
|
// Dedup + map a raw findings array (from analyzeFunction's callContext)
|
|
@@ -2654,6 +2676,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2654
2676
|
// rebuilding the SMT path-feasibility mechanism (next-gen taint
|
|
2655
2677
|
// capability #5) to depend on a REAL slice for a sound proof.
|
|
2656
2678
|
...(f.argIndex !== undefined ? { argIndex: f.argIndex } : {}),
|
|
2679
|
+
...(f._argCallee !== undefined ? { _argCallee: f._argCallee } : {}),
|
|
2657
2680
|
// callee: kept as plain `callee` (not underscore-prefixed) to match
|
|
2658
2681
|
// backward.js's own contract, which reads `f.callee` on both real and
|
|
2659
2682
|
// fake-fixture findings throughout its module and test suite. It is
|
package/src/engine.js
CHANGED
|
@@ -9228,6 +9228,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9228
9228
|
const annotatedComponents=components.map(c=>{const key=`${c.ecosystem}:${c.name}:${c.version}`;const vulns=vulnsByKey[key]||[];const riKey=c.ecosystem==='maven'&&c.group?`maven:${c.group}/${c.name}`:`${c.ecosystem}:${c.name}`;const ri=registryInfo.get(riKey)||{};const latestVersion=ri.latestVersion||'';const vd=(ri.versions||{})[c.version]||{};const isDeprecated=typeof vd.deprecated==='string'&&vd.deprecated.length>0;const deprecationMessage=isDeprecated?vd.deprecated:'';const isOutdated=!isDeprecated&&typeof vd.outdated==='string'&&vd.outdated.length>0;const outdatedMessage=isOutdated?vd.outdated:'';const license=ri.license||vd.license||'';return{...c,vulns,hasVulns:vulns.length>0,hasAttackPath:attackResult.flagged.has(key),attackPaths:attackResult.pathsByKey.get(key)||[],latestVersion,isDeprecated,deprecationMessage,isOutdated,outdatedMessage,license};});
|
|
9229
9229
|
// X-010: Hackage and Nix components, dependency edges and target provenance. Kept beside (not inside) the ordinary
|
|
9230
9230
|
// component list so the existing consumers are untouched; the BOM emitters and the SBOM diff read it explicitly.
|
|
9231
|
+
try{if(process.env.AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE==='1'){const{prefetchHackageFeed}=await import('./language/haskell-supply.js');const{resolvedHackageComponents,nixHaskellPackageNames}=await import('./language/resolved-pass.js');let _rc=[],_nx=[];try{_rc=resolvedHackageComponents(allFileContents).components||[];}catch(_){}try{_nx=nixHaskellPackageNames(allFileContents);}catch(_){}await prefetchHackageFeed(allFileContents,{resolvedComponents:_rc,extraNames:_nx});}}catch(_){/* the live feed is optional: a failure leaves the scan on whatever snapshot exists */}
|
|
9231
9232
|
let _languageBom=null;try{if(Object.keys(allFileContents).some(f=>/\.(?:l?hs|nix)$|\.cabal$|(?:^|\/)(?:cabal\.project(?:\.freeze)?|package\.yaml|stack\.yaml(?:\.lock)?|flake\.lock)$/i.test(f))){const{languageBom}=await import('./language/bom.js');const{resolvedHaskellGraph,nixClosureOf}=await import('./language/resolved-pass.js');const _rg=resolvedHaskellGraph(allFileContents);const _nc=nixClosureOf(allFileContents);_languageBom=languageBom(allFileContents,{...(_rg?{resolved:_rg.graph}:{}),...(_nc&&!_nc.refused.length?{closure:_nc.closure}:{})});}}catch(_){_languageBom=null;}
|
|
9232
9233
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
|
|
9233
9234
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanCsharpCrossFile',()=>scanCsharpCrossFile(fc))||[]));
|
package/src/language/CLAUDE.md
CHANGED
|
@@ -26,6 +26,11 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
|
|
|
26
26
|
outside the project root is refused (`fix-lifecycle.js` `isRootRelativePath`).
|
|
27
27
|
- **Secrets never leave redacted.** `secrets.js` `redactLanguageSecrets` joins split literals before masking; egress and model
|
|
28
28
|
prompts use it. Credential values are never written to any output or state file.
|
|
29
|
+
- **The advisory feed is opt-in and records coverage.** `haskell-advisory-feed.js` touches the network only when
|
|
30
|
+
`AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE=1` and never under `AGENTIC_SECURITY_OFFLINE`. Its snapshot lists the packages it actually
|
|
31
|
+
queried (`covered`); a package it did not cover is `feed-incomplete` and a stale one `feed-stale`, in BOTH matchers
|
|
32
|
+
(`evaluateComponents` and `nix-sca.js`). Any new consumer of an `AdvisoryDb` must check `db.coverage(name)` before saying "no advisory".
|
|
33
|
+
The snapshot lives in the operator configuration directory, never the project (`trusted-inputs.js`).
|
|
29
34
|
- **Pragma** (`pragma.js`): comment-aware, line-scoped, exact rule-id match, logged. A line-less finding cannot be suppressed.
|
|
30
35
|
|
|
31
36
|
## Module map
|
|
@@ -33,7 +38,7 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
|
|
|
33
38
|
| Group | Files |
|
|
34
39
|
|---|---|
|
|
35
40
|
| Shared | `discovery.js` (sources, manifests, explicit exports, exclusions, import graph, invalidation digests), `contracts.js`, `assurance.js` (scan-health inputs, conditions, limitations), `engine-pass.js` (language supply chain wired into the engine), `resolved-pass.js` (plan / Stack export / Nix closure / opt-in evaluation reachable from the CLI), `context.js` (project context for partial scans, fix previews and dependency upgrades, model prompt extras), `state-artifacts.js`, `pragma.js`, `secrets.js`, `bom.js`, `bom-validate.js`, `aibom.js`, `compliance-map.js`, `bridges.js`, `witness.js`, `fix-lifecycle.js` (the one fix lifecycle: path, syntax, rescan, compile, backup, history, undo) |
|
|
36
|
-
| Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-supply.js`, `haskell-fix.js` |
|
|
41
|
+
| Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-advisory-feed.js` (the opt-in live Hackage feed: OSV fetch, per-package coverage, operator-only snapshot), `haskell-supply.js`, `haskell-fix.js` |
|
|
37
42
|
| Nix | `nix-parser.js`, `nix-grammar.js`, `nix-ir.js`, `nix-adapter.js`, `nixos-module-resolver.js`, `nixos-option-catalog.js`, `nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`, `nix-script-taint.js`, `nix-privacy.js`, `nix-agents.js`, `nix-inventory.js`, `nix-closure.js`, `nix-sca.js`, `nix-eval-isolation.js`, `nix-fix.js` |
|
|
38
43
|
| Measurement | `accuracy.js` (family-scoped scoring, per-layer), `support-registry.js` (status rules, promotion refusals, frozen-hash demotion) |
|
|
39
44
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Live Hackage advisory feed (HS-009 follow-on). The matching logic in haskell-sca.js takes records from its caller and never fetches;
|
|
2
|
+
// this module is the caller that can. It turns the OSV `Hackage` ecosystem (HSEC advisories and their aliases) into the same
|
|
3
|
+
// hash-recorded snapshot the loader already verifies, and writes it where only the operator can: the per-user configuration
|
|
4
|
+
// directory. A scanned project cannot supply or alter it (see trusted-inputs.js).
|
|
5
|
+
//
|
|
6
|
+
// Contract, in the order it matters:
|
|
7
|
+
// * Opt in. Nothing here touches the network unless AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE=1, and never when
|
|
8
|
+
// AGENTIC_SECURITY_OFFLINE=1. An operator-pinned snapshot (AGENTIC_SECURITY_HACKAGE_ADVISORIES) always wins: it is not refreshed over.
|
|
9
|
+
// * Coverage is recorded, not assumed. The snapshot lists each package it actually queried and when. A package that was not queried,
|
|
10
|
+
// or whose query failed part-way, is NOT covered, and the matcher reports it `feed-incomplete` (unknown), never `no-advisories` (clean).
|
|
11
|
+
// * Failure degrades, it does not abort. An unreachable feed leaves the previous snapshot in place and the scan runs on it, with its age
|
|
12
|
+
// disclosed; with no previous snapshot the scan reports `feed-unavailable`, exactly as before this module existed.
|
|
13
|
+
// * Everything from the wire is untrusted: ids and package names are validated before they are placed in a URL or a path, sizes are
|
|
14
|
+
// bounded, and a record that does not parse as an OSV record is dropped and counted.
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { buildSnapshot, loadAdvisorySnapshot } from './haskell-sca.js';
|
|
19
|
+
import { operatorConfigDir } from './trusted-inputs.js';
|
|
20
|
+
|
|
21
|
+
export const FEED_ENV = 'AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE';
|
|
22
|
+
export const PINNED_ENV = 'AGENTIC_SECURITY_HACKAGE_ADVISORIES';
|
|
23
|
+
export const SNAPSHOT_FILE = 'hackage-advisories.json';
|
|
24
|
+
export const OSV_BASE = 'https://api.osv.dev';
|
|
25
|
+
|
|
26
|
+
const DEFAULTS = Object.freeze({
|
|
27
|
+
ttlMs: 24 * 3600 * 1000, // a covered package is not queried again inside this window
|
|
28
|
+
timeoutMs: 8000,
|
|
29
|
+
concurrency: 10,
|
|
30
|
+
batchSize: 500,
|
|
31
|
+
maxPackages: 500,
|
|
32
|
+
maxRecordBytes: 1 << 20,
|
|
33
|
+
maxSnapshotRecords: 20000,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const NAME_OK = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/; // Hackage package names
|
|
37
|
+
const ID_OK = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/; // HSEC-2023-0001, GHSA-..., CVE-...
|
|
38
|
+
|
|
39
|
+
let last = null;
|
|
40
|
+
/** What the most recent refresh in this process did, so a "no feed" reason can say why. */
|
|
41
|
+
export const getLastRefresh = () => last;
|
|
42
|
+
export const _resetLastRefresh = () => { last = null; };
|
|
43
|
+
|
|
44
|
+
export function liveFeedEnabled(env = process.env) {
|
|
45
|
+
return env[FEED_ENV] === '1' && env.AGENTIC_SECURITY_OFFLINE !== '1';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function snapshotPath(env = process.env, dir = null) {
|
|
49
|
+
return join(dir || operatorConfigDir(env), SNAPSHOT_FILE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readCurrent(path, maxAgeDays) {
|
|
53
|
+
let raw;
|
|
54
|
+
try { raw = JSON.parse(readFileSync(path, 'utf8')); } catch { return { records: new Map(), covered: {}, problem: null }; }
|
|
55
|
+
// Integrity first: a cache whose records do not match their own hashes is discarded, not repaired.
|
|
56
|
+
const r = loadAdvisorySnapshot(raw, { maxAgeDays });
|
|
57
|
+
if (!r.ok) return { records: new Map(), covered: {}, problem: `the cached snapshot was discarded: ${r.reason}` };
|
|
58
|
+
const covered = raw.covered && typeof raw.covered === 'object' ? { ...raw.covered } : {};
|
|
59
|
+
return { records: new Map(raw.records.map((x) => [x.id, x])), covered, problem: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function getJson(fetchImpl, url, init, timeoutMs, maxBytes) {
|
|
63
|
+
const resp = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
64
|
+
if (!resp || resp.status !== 200) throw new Error(`HTTP ${resp ? resp.status : 'none'}`);
|
|
65
|
+
const text = await resp.text();
|
|
66
|
+
if (Buffer.byteLength(text) > maxBytes) throw new Error('response too large');
|
|
67
|
+
return JSON.parse(text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function pool(items, limit, worker) {
|
|
71
|
+
const out = new Array(items.length);
|
|
72
|
+
let i = 0;
|
|
73
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
74
|
+
while (i < items.length) { const k = i++; out[k] = await worker(items[k], k); }
|
|
75
|
+
}));
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Refresh the operator's Hackage advisory snapshot for the given package names.
|
|
81
|
+
* Never throws. Returns a plain result describing what happened; the snapshot file is only rewritten when something changed.
|
|
82
|
+
*
|
|
83
|
+
* @param {string[]} names Hackage package names the scan is about to evaluate
|
|
84
|
+
* @param {{env?: object, fetchImpl?: Function, now?: number, dir?: string, ttlMs?: number, timeoutMs?: number}} [opts]
|
|
85
|
+
*/
|
|
86
|
+
export async function refreshHackageAdvisories(names, opts = {}) {
|
|
87
|
+
const env = opts.env || process.env;
|
|
88
|
+
const cfg = { ...DEFAULTS, ...Object.fromEntries(Object.entries(opts).filter(([k]) => k in DEFAULTS)) };
|
|
89
|
+
const now = opts.now ?? Date.now();
|
|
90
|
+
const done = (r) => { last = { ...r, at: new Date(now).toISOString() }; return last; };
|
|
91
|
+
|
|
92
|
+
if (env[PINNED_ENV]) return done({ status: 'pinned-snapshot-in-use', detail: `${PINNED_ENV} names an operator snapshot; it is used as given and is not refreshed` });
|
|
93
|
+
if (env[FEED_ENV] !== '1') return done({ status: 'disabled', detail: `set ${FEED_ENV}=1 to fetch Hackage advisories from the OSV feed` });
|
|
94
|
+
if (env.AGENTIC_SECURITY_OFFLINE === '1') return done({ status: 'offline', detail: 'AGENTIC_SECURITY_OFFLINE=1: no advisory data was fetched' });
|
|
95
|
+
|
|
96
|
+
const wanted = [...new Set((names || []).filter((n) => typeof n === 'string' && NAME_OK.test(n)))].sort().slice(0, cfg.maxPackages);
|
|
97
|
+
if (!wanted.length) return done({ status: 'nothing-to-do', detail: 'no Hackage packages to look up' });
|
|
98
|
+
|
|
99
|
+
const path = snapshotPath(env, opts.dir);
|
|
100
|
+
const cur = readCurrent(path, 36500);
|
|
101
|
+
const need = wanted.filter((n) => { const t = Date.parse(cur.covered[n]); return !Number.isFinite(t) || now - t > cfg.ttlMs || t > now + 3600000; });
|
|
102
|
+
if (!need.length) return done({ status: 'current', detail: `${wanted.length} package(s) covered within the last ${Math.round(cfg.ttlMs / 3600000)}h; no request made`, packages: wanted.length, requested: 0 });
|
|
103
|
+
|
|
104
|
+
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
105
|
+
if (typeof fetchImpl !== 'function') return done({ status: 'failed', detail: 'no fetch implementation is available in this runtime' });
|
|
106
|
+
|
|
107
|
+
const failures = [];
|
|
108
|
+
const ok = new Set(); // packages whose whole answer was obtained
|
|
109
|
+
const refs = new Map(); // package -> [{id, modified}]
|
|
110
|
+
// 1. which advisories does OSV hold for each package?
|
|
111
|
+
for (let i = 0; i < need.length; i += cfg.batchSize) {
|
|
112
|
+
const chunk = need.slice(i, i + cfg.batchSize);
|
|
113
|
+
try {
|
|
114
|
+
const data = await getJson(fetchImpl, `${OSV_BASE}/v1/querybatch`, {
|
|
115
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify({ queries: chunk.map((name) => ({ package: { name, ecosystem: 'Hackage' } })) }),
|
|
117
|
+
}, cfg.timeoutMs, 8 << 20);
|
|
118
|
+
const results = Array.isArray(data && data.results) ? data.results : [];
|
|
119
|
+
chunk.forEach((name, k) => {
|
|
120
|
+
const res = results[k];
|
|
121
|
+
if (!res || typeof res !== 'object') { failures.push(`${name}: no result`); return; }
|
|
122
|
+
if (res.next_page_token) { failures.push(`${name}: more advisories than one page; not treated as covered`); return; }
|
|
123
|
+
refs.set(name, (Array.isArray(res.vulns) ? res.vulns : []).filter((v) => v && typeof v.id === 'string' && ID_OK.test(v.id)).map((v) => ({ id: v.id, modified: v.modified || null })));
|
|
124
|
+
});
|
|
125
|
+
} catch (e) { for (const n of chunk) failures.push(`${n}: ${String((e && e.message) || e).slice(0, 80)}`); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 2. fetch the records that are new or newer than the cached copy
|
|
129
|
+
const toFetch = new Map();
|
|
130
|
+
for (const list of refs.values()) for (const { id, modified } of list) {
|
|
131
|
+
const have = cur.records.get(id);
|
|
132
|
+
if (!have || (modified && have.modified && Date.parse(modified) > Date.parse(have.modified)) || (modified && !have.modified)) toFetch.set(id, modified);
|
|
133
|
+
}
|
|
134
|
+
const fetched = new Map();
|
|
135
|
+
const badIds = new Set();
|
|
136
|
+
await pool([...toFetch.keys()], cfg.concurrency, async (id) => {
|
|
137
|
+
try {
|
|
138
|
+
const rec = await getJson(fetchImpl, `${OSV_BASE}/v1/vulns/${encodeURIComponent(id)}`, {}, cfg.timeoutMs, cfg.maxRecordBytes);
|
|
139
|
+
if (!rec || typeof rec !== 'object' || rec.id !== id) throw new Error('not the requested record');
|
|
140
|
+
fetched.set(id, rec);
|
|
141
|
+
} catch (e) { badIds.add(id); failures.push(`${id}: ${String((e && e.message) || e).slice(0, 80)}`); }
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// 3. a package is covered only if every advisory it references is in hand
|
|
145
|
+
const records = new Map(cur.records);
|
|
146
|
+
for (const [id, rec] of fetched) records.set(id, rec);
|
|
147
|
+
const covered = { ...cur.covered };
|
|
148
|
+
const iso = new Date(now).toISOString();
|
|
149
|
+
let newlyCovered = 0;
|
|
150
|
+
for (const [name, list] of refs) {
|
|
151
|
+
if (list.some((v) => badIds.has(v.id) || !records.has(v.id))) continue;
|
|
152
|
+
covered[name] = iso; ok.add(name); newlyCovered++;
|
|
153
|
+
}
|
|
154
|
+
const uncovered = need.filter((n) => !ok.has(n));
|
|
155
|
+
|
|
156
|
+
if (!ok.size && !fetched.size) {
|
|
157
|
+
return done({ status: 'failed', detail: `the OSV feed could not be read (${failures.slice(0, 2).join('; ') || 'no response'}); ${cur.records.size ? 'the previous snapshot is used and its age is disclosed' : 'no advisory data is available'}`, packages: wanted.length, requested: need.length, uncovered, failures: failures.slice(0, 10), cacheProblem: cur.problem });
|
|
158
|
+
}
|
|
159
|
+
if (records.size > cfg.maxSnapshotRecords) return done({ status: 'failed', detail: `the snapshot would exceed ${cfg.maxSnapshotRecords} records; refusing to write it`, packages: wanted.length, requested: need.length, uncovered });
|
|
160
|
+
|
|
161
|
+
// 4. write the snapshot atomically, readable by the operator only
|
|
162
|
+
const snap = buildSnapshot([...records.values()].sort((a, b) => (a.id < b.id ? -1 : 1)), iso);
|
|
163
|
+
snap.covered = Object.fromEntries(Object.entries(covered).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
164
|
+
try {
|
|
165
|
+
const dir = opts.dir || operatorConfigDir(env);
|
|
166
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
167
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
168
|
+
writeFileSync(tmp, JSON.stringify(snap), { mode: 0o600 });
|
|
169
|
+
try { chmodSync(tmp, 0o600); } catch { /* best effort on filesystems without modes */ }
|
|
170
|
+
renameSync(tmp, path);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
return done({ status: 'failed', detail: `the snapshot could not be written: ${e.code || e.message}`, packages: wanted.length, requested: need.length, uncovered });
|
|
173
|
+
}
|
|
174
|
+
return done({
|
|
175
|
+
status: uncovered.length ? 'partial' : 'refreshed',
|
|
176
|
+
detail: `${newlyCovered} package(s) covered, ${fetched.size} advisory record(s) fetched${uncovered.length ? `; ${uncovered.length} package(s) NOT covered (${uncovered.slice(0, 3).join(', ')}${uncovered.length > 3 ? ', ...' : ''}) and will be reported as unknown` : ''}`,
|
|
177
|
+
packages: wanted.length, requested: need.length, fetched: fetched.size, records: records.size, uncovered, failures: failures.slice(0, 10), path, cacheProblem: cur.problem,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -40,7 +40,7 @@ export const HS_PACKAGES = Object.freeze({
|
|
|
40
40
|
yesod: { modules: ['Yesod.Core', 'Yesod'], tested: ['1.6'] },
|
|
41
41
|
'http-client': { modules: ['Network.HTTP.Client', 'Network.HTTP.Client.TLS'], tested: ['0.7'] },
|
|
42
42
|
'http-conduit': { modules: ['Network.HTTP.Simple', 'Network.HTTP.Conduit'], tested: ['2.3'] },
|
|
43
|
-
wreq: { modules: ['Network.Wreq'], tested: ['0.5'] },
|
|
43
|
+
wreq: { modules: ['Network.Wreq', 'Network.Wreq.Session'], tested: ['0.5'] },
|
|
44
44
|
'blaze-html': { modules: ['Text.Blaze', 'Text.Blaze.Html', 'Text.Blaze.Html5', 'Text.Blaze.Internal'], tested: ['0.9'] },
|
|
45
45
|
lucid: { modules: ['Lucid', 'Lucid.Base'], tested: ['2.11'] },
|
|
46
46
|
shakespeare: { modules: ['Text.Hamlet', 'Text.Shakespeare'], tested: ['2.0'] },
|
|
@@ -127,7 +127,13 @@ sink('Network.HTTP.Simple', ['httpBS', 'httpLBS', 'httpJSON', 'httpNoBody', 'htt
|
|
|
127
127
|
sink('Network.HTTP.Conduit', ['simpleHttp', 'parseRequest', 'parseUrlThrow', 'parseRequest_', 'httpLbs'], { ...SSRF, package: 'http-conduit' });
|
|
128
128
|
sink('Network.HTTP.Client', ['parseRequest', 'parseUrlThrow', 'parseRequest_', 'requestFromURI'], { ...SSRF, package: 'http-client' });
|
|
129
129
|
sink('Network.HTTP.Client', ['httpLbs', 'httpNoBody', 'withResponse', 'responseOpen'], { ...SSRF, argIndex: 0, package: 'http-client' });
|
|
130
|
-
|
|
130
|
+
// wreq: where the URL sits depends on the function. `get url`, but `getWith opts url` (the first argument is the Options, not the target),
|
|
131
|
+
// `customMethod method url`, `customMethodWith method opts url`; the Session forms take the session before the URL.
|
|
132
|
+
sink('Network.Wreq', ['get', 'post', 'put', 'delete', 'head', 'options', 'patch'], { ...SSRF, argIndex: 0, package: 'wreq' });
|
|
133
|
+
sink('Network.Wreq', ['getWith', 'postWith', 'putWith', 'deleteWith', 'headWith', 'optionsWith', 'patchWith', 'customMethod', 'customPayloadMethod'], { ...SSRF, argIndex: 1, package: 'wreq' });
|
|
134
|
+
sink('Network.Wreq', ['customMethodWith', 'customPayloadMethodWith'], { ...SSRF, argIndex: 2, package: 'wreq' });
|
|
135
|
+
sink('Network.Wreq.Session', ['get', 'post', 'put', 'delete', 'head', 'options', 'patch'], { ...SSRF, argIndex: 1, package: 'wreq' });
|
|
136
|
+
sink('Network.Wreq.Session', ['getWith', 'postWith', 'putWith', 'deleteWith', 'headWith', 'optionsWith', 'patchWith'], { ...SSRF, argIndex: 2, package: 'wreq' });
|
|
131
137
|
|
|
132
138
|
// CWE-1427. Untrusted text placed in a model request body. The call is an ordinary HTTP body setter, so the
|
|
133
139
|
// finding is kept only for a file that shows AI evidence (an AI endpoint, model literal or SDK import):
|
|
@@ -147,8 +147,12 @@ const fixedVersions = (ranges) => [...new Set((ranges || []).flatMap((r) => (r.e
|
|
|
147
147
|
|
|
148
148
|
// ── advisory database (pinned snapshot / records) ────────────────────────────
|
|
149
149
|
export class AdvisoryDb {
|
|
150
|
-
constructor({ records = [], source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30, integrity = null } = {}) {
|
|
150
|
+
constructor({ records = [], source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30, integrity = null, covered = null } = {}) {
|
|
151
151
|
this.source = source;
|
|
152
|
+
// `covered` is {package: ISO time it was last queried}. null means the snapshot makes no per-package claim (a hand-built or
|
|
153
|
+
// operator-pinned snapshot): every package is then treated as covered, as it always was.
|
|
154
|
+
this.covered = covered && typeof covered === 'object' ? covered : null;
|
|
155
|
+
this.now = now;
|
|
152
156
|
this.generatedAt = generatedAt;
|
|
153
157
|
this.integrity = integrity;
|
|
154
158
|
this.problems = [];
|
|
@@ -167,6 +171,13 @@ export class AdvisoryDb {
|
|
|
167
171
|
this.maxAgeDays = maxAgeDays;
|
|
168
172
|
}
|
|
169
173
|
forPackage(name) { return this.byPackage.get(name) || []; }
|
|
174
|
+
/** 'covered' | 'uncovered' | 'stale' for a package; always 'covered' when the snapshot makes no per-package claim. */
|
|
175
|
+
coverage(name) {
|
|
176
|
+
if (!this.covered) return 'covered';
|
|
177
|
+
const t = Date.parse(this.covered[name]);
|
|
178
|
+
if (!Number.isFinite(t)) return 'uncovered';
|
|
179
|
+
return (this.now - t) / 86400000 > this.maxAgeDays ? 'stale' : 'covered';
|
|
180
|
+
}
|
|
170
181
|
}
|
|
171
182
|
|
|
172
183
|
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
@@ -185,7 +196,7 @@ export function loadAdvisorySnapshot(snapshot, { pinnedSha256 = null, signature
|
|
|
185
196
|
if (!want) return refuse(`record ${rec.id} has no recorded hash`);
|
|
186
197
|
if (sha256(JSON.stringify(rec)) !== want) return refuse(`record ${rec.id} does not match its recorded hash`);
|
|
187
198
|
}
|
|
188
|
-
const body =
|
|
199
|
+
const body = snapshotBody(snapshot);
|
|
189
200
|
const digest = sha256(body);
|
|
190
201
|
if (pinnedSha256 && digest !== pinnedSha256) return refuse('snapshot hash does not match the pinned value');
|
|
191
202
|
let signed = false;
|
|
@@ -197,7 +208,7 @@ export function loadAdvisorySnapshot(snapshot, { pinnedSha256 = null, signature
|
|
|
197
208
|
signed = true;
|
|
198
209
|
} catch (e) { return refuse(`signature check failed: ${e.message}`); }
|
|
199
210
|
}
|
|
200
|
-
const db = new AdvisoryDb({ records: snapshot.records, source: 'snapshot', generatedAt: snapshot.generatedAt, now, maxAgeDays, integrity: { sha256: digest, hashPinned: !!pinnedSha256, signed } });
|
|
211
|
+
const db = new AdvisoryDb({ records: snapshot.records, source: 'snapshot', generatedAt: snapshot.generatedAt, now, maxAgeDays, integrity: { sha256: digest, hashPinned: !!pinnedSha256, signed }, covered: snapshot.covered || null });
|
|
201
212
|
return { ok: true, db, digest };
|
|
202
213
|
}
|
|
203
214
|
|
|
@@ -205,7 +216,8 @@ export function buildSnapshot(records, generatedAt) {
|
|
|
205
216
|
const recordHashes = Object.fromEntries(records.map((r) => [r.id, sha256(JSON.stringify(r))]));
|
|
206
217
|
return { schema: 1, generatedAt, records, recordHashes };
|
|
207
218
|
}
|
|
208
|
-
|
|
219
|
+
// `covered` joins the hashed body only when present, so a snapshot without it keeps the digest it always had.
|
|
220
|
+
export const snapshotBody = (s) => JSON.stringify({ schema: s.schema, generatedAt: s.generatedAt, records: s.records, recordHashes: s.recordHashes, ...(s.covered ? { covered: s.covered } : {}) });
|
|
209
221
|
|
|
210
222
|
// ── matching ─────────────────────────────────────────────────────────────────
|
|
211
223
|
/**
|
|
@@ -251,6 +263,8 @@ export function evaluateComponents(components, db, opts = {}) {
|
|
|
251
263
|
for (const comp of components) {
|
|
252
264
|
const base = { name: comp.name, version: comp.version || null, declaredRange: comp.declaredRange || null, scope: comp.scope || null, target: comp.target || null, purl: hackagePurl(comp.name, comp.version || null) };
|
|
253
265
|
if (!db) { statuses.push({ ...base, status: 'feed-unavailable' }); continue; }
|
|
266
|
+
const cov = db.coverage(comp.name);
|
|
267
|
+
if (cov !== 'covered') { statuses.push({ ...base, status: cov === 'stale' ? 'feed-stale' : 'feed-incomplete', reason: cov === 'stale' ? 'this package was last looked up longer ago than the feed age limit; the absence of advisories is not evidence' : 'the advisory feed never covered this package; the absence of advisories is not evidence' }); continue; }
|
|
254
268
|
const hits = db.forPackage(comp.name);
|
|
255
269
|
if (!hits.length) { statuses.push({ ...base, status: 'no-advisories', feed: feed.status }); continue; }
|
|
256
270
|
let any = false;
|
|
@@ -26,7 +26,7 @@ export const RULES = Object.freeze({
|
|
|
26
26
|
'hs-ecb-mode': { cwe: 'CWE-327', severity: 'high', family: 'weak-crypto', vuln: 'ECB block cipher mode', remediation: 'ECB leaks plaintext structure. Use an AEAD mode (AES-GCM) with a fresh nonce.' },
|
|
27
27
|
'hs-static-iv': { cwe: 'CWE-329', severity: 'high', family: 'weak-crypto', vuln: 'Static IV or nonce', remediation: 'Generate a fresh random IV/nonce per message with `getRandomBytes`; never reuse one with the same key.' },
|
|
28
28
|
'hs-predictable-random': { cwe: 'CWE-338', severity: 'high', family: 'weak-randomness', vuln: 'Predictable random number generator used for a security value', remediation: 'Use `Crypto.Random.getRandomBytes` or `System.Entropy.getEntropy` (OS CSPRNG). `System.Random` is a deterministic PRNG.' },
|
|
29
|
-
'hs-unbounded-request-body': { cwe: 'CWE-
|
|
29
|
+
'hs-unbounded-request-body': { cwe: 'CWE-770', severity: 'medium', family: 'resource-exhaustion', vuln: 'Request body read without a size limit', remediation: 'Apply `requestSizeLimitMiddleware` (wai-extra) or read a bounded number of chunks with `getRequestBodyChunk`.' },
|
|
30
30
|
'hs-xml-entity-substitution': { cwe: 'CWE-611', severity: 'medium', family: 'xxe', vuln: 'XML parser configured to substitute DTD entities', remediation: 'Disable DTD entity substitution (`withSubstDTDEntities no`) for untrusted XML.' },
|
|
31
31
|
'hs-sensitive-logging': { cwe: 'CWE-532', severity: 'high', family: 'sensitive-logging', vuln: 'Sensitive value written to a log or trace', remediation: 'Do not log credentials or secrets. Log an identifier, or redact the value before it reaches the logger.' },
|
|
32
32
|
'hs-insecure-cookie': { cwe: 'CWE-1004', severity: 'medium', family: 'cookie-attributes', vuln: 'Session cookie without HttpOnly or Secure', remediation: 'Set `setCookieHttpOnly = True`, `setCookieSecure = True` and `setCookieSameSite = Just sameSiteStrict` (or Lax) on a session or token cookie.' },
|
|
@@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { analyzeHaskellManifests } from './haskell-manifests.js';
|
|
8
8
|
import { buildHaskellIR } from './haskell-ir.js';
|
|
9
|
+
import { getLastRefresh, refreshHackageAdvisories, liveFeedEnabled, FEED_ENV } from './haskell-advisory-feed.js';
|
|
9
10
|
import { AdvisoryDb, loadAdvisorySnapshot, evaluateComponents, reachability, nearNameCandidates, sourceIntegrity, lifecycle, licensePolicy, hackagePurl, GHC_BOOT_PACKAGES } from './haskell-sca.js';
|
|
10
11
|
|
|
11
12
|
const MANIFEST = /(?:^|\/)(?:[^/]+\.cabal|cabal\.project(?:\.[a-z]+)?|package\.yaml|stack\.yaml|stack\.yaml\.lock)$/;
|
|
@@ -61,7 +62,18 @@ function mergeResolved(declared, resolved) {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/** Advisory data configured for this scan: a hash-pinned snapshot from the environment or the project's state dir. */
|
|
65
|
+
/** The live-feed sentence appended to a "no advisory data" reason: what the refresh did, or how to turn it on. */
|
|
66
|
+
function liveFeedNote(env) {
|
|
67
|
+
const r = getLastRefresh();
|
|
68
|
+
if (r && r.status !== 'disabled') return ` Live feed: ${r.status}, ${r.detail}.`;
|
|
69
|
+
return env[FEED_ENV] === '1' ? '' : ` To fetch advisories from the OSV Hackage feed instead, set ${FEED_ENV}=1 (network, opt-in).`;
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
export function configuredAdvisoryDb(root, env = process.env) {
|
|
73
|
+
const c = _configuredAdvisoryDb(root, env);
|
|
74
|
+
return c.db ? c : { ...c, reason: `${c.reason}${liveFeedNote(env)}` };
|
|
75
|
+
}
|
|
76
|
+
function _configuredAdvisoryDb(root, env = process.env) {
|
|
65
77
|
const sel = resolveOperatorSnapshot({ envVar: 'AGENTIC_SECURITY_HACKAGE_ADVISORIES', fileName: 'hackage-advisories.json', root, env });
|
|
66
78
|
if (!sel.path) return { db: null, reason: `no advisory snapshot configured (set AGENTIC_SECURITY_HACKAGE_ADVISORIES, or place hackage-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? IGNORED_NOTE('hackage-advisories.json') : ''}`, projectLocalIgnored: !!sel.projectLocalIgnored };
|
|
67
79
|
let snap;
|
|
@@ -70,6 +82,20 @@ export function configuredAdvisoryDb(root, env = process.env) {
|
|
|
70
82
|
return r.ok ? { db: r.db, reason: null, source: sel.source } : { db: null, reason: `advisory snapshot refused: ${r.reason}` };
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The async step before a scan's synchronous passes: when the live feed is enabled, make sure the operator snapshot covers every
|
|
87
|
+
* Hackage package this scan will evaluate (declared, frozen, transitive when a plan is present, and Haskell packages inside an imported Nix closure). Does nothing, and costs nothing
|
|
88
|
+
* (no manifest parsing), unless the feed is enabled. Never throws.
|
|
89
|
+
*/
|
|
90
|
+
export async function prefetchHackageFeed(files, { env = process.env, resolvedComponents = [], extraNames = [], ...opts } = {}) {
|
|
91
|
+
if (!liveFeedEnabled(env)) return null;
|
|
92
|
+
try {
|
|
93
|
+
const names = [...hackageComponents(files).components, ...resolvedComponents].map((c) => c.name).concat(extraNames);
|
|
94
|
+
if (!names.length) return null;
|
|
95
|
+
return await refreshHackageAdvisories(names, { env, ...opts });
|
|
96
|
+
} catch (e) { return { status: 'failed', detail: `the feed step failed: ${String((e && e.message) || e).slice(0, 120)}` }; }
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
/** Imports and import-qualified callees across the project's Haskell sources. */
|
|
74
100
|
export function collectUsage(files) {
|
|
75
101
|
const hs = {};
|
|
@@ -101,6 +127,8 @@ export function analyzeHaskellSupply(files, { db = undefined, root = null, env =
|
|
|
101
127
|
out.feed = ev.feed;
|
|
102
128
|
if (!db) out.gaps.push({ kind: 'advisory-feed-unavailable', detail: `${feedReason || ev.feed.detail}. ${components.length} Hackage dependencies were NOT checked against any advisory: the absence of findings is not a clean result.` });
|
|
103
129
|
else if (ev.feed.status === 'stale-cache') out.gaps.push({ kind: 'advisory-feed-stale', detail: ev.feed.detail });
|
|
130
|
+
const notCovered = ev.statuses.filter((x) => x.status === 'feed-incomplete' || x.status === 'feed-stale');
|
|
131
|
+
if (db && notCovered.length) out.gaps.push({ kind: 'advisory-feed-incomplete', detail: `${notCovered.length} Hackage package(s) were not covered by the advisory feed (${[...new Set(notCovered.map((x) => x.name))].slice(0, 5).join(', ')}${notCovered.length > 5 ? ', ...' : ''}): their status is unknown, and the absence of findings for them is not a clean result.` });
|
|
104
132
|
const unresolved = components.filter((c) => !c.version);
|
|
105
133
|
if (unresolved.length) out.gaps.push({ kind: 'unresolved-dependency-versions', detail: `${unresolved.length} dependency version(s) are only declared ranges (${unresolved.slice(0, 5).map((c) => c.name).join(', ')}${unresolved.length > 5 ? ', ...' : ''}); generate a Cabal plan or a Stack export to resolve them` });
|
|
106
134
|
|
|
@@ -221,6 +221,32 @@ function handlerFacts(ctx, fn) {
|
|
|
221
221
|
};
|
|
222
222
|
const guardIdx = ordered.findIndex((o) => isGuardCall(o.call));
|
|
223
223
|
const sensIdx = ordered.findIndex((o) => isSensitive(o.call));
|
|
224
|
+
// An INLINE guard: the handler itself reads a credential and, before any sensitive operation, rejects. Scotty's `status` only sets the
|
|
225
|
+
// code and the handler keeps running, so there a halting call (`finish`, `raise`, ...) must follow, still before the sensitive step;
|
|
226
|
+
// a WAI response, a Servant throwError or a Yesod permissionDenied ends the handler by itself.
|
|
227
|
+
// `finish` is usually a bare operand (`status status401 >> finish`, `when bad finish`), which the IR records as an identifier
|
|
228
|
+
// rather than a call, so halting points are collected from both, by source line.
|
|
229
|
+
const HALT = /^Web\.Scotty(?:\.Trans)?\.(?:finish|raise|raiseStatus|redirect)$/;
|
|
230
|
+
const haltLines = [];
|
|
231
|
+
for (const n of nodes) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
|
|
232
|
+
if ((e.kind === 'call' ? e.callee : e.kind === 'ident' ? e.name : null) && HALT.test(e.kind === 'call' ? e.callee : e.name)) haltLines.push(Number.isFinite(e.line) ? e.line : n.line);
|
|
233
|
+
}
|
|
234
|
+
const sensLine = sensIdx >= 0 ? ordered[sensIdx].line : Infinity;
|
|
235
|
+
const haltsAfter = (line) => haltLines.some((h) => Number.isFinite(h) && h >= line && h < sensLine);
|
|
236
|
+
// The complete call list (not the node walk, which does not descend into case alternatives), ordered by source line.
|
|
237
|
+
const allCalls = callsOf(fn).filter((c) => typeof c.callee === 'string' && Number.isFinite(c.line)).sort((x, y) => x.line - y.line);
|
|
238
|
+
let inlineIdx = -1, inlineKind = null, inlineLine = null, inlineCallee = null;
|
|
239
|
+
for (let i = 0; i < allCalls.length && inlineIdx < 0; i++) {
|
|
240
|
+
const k = credentialKind(allCalls[i]);
|
|
241
|
+
if (!k) continue;
|
|
242
|
+
for (let j = i + 1; j < allCalls.length && allCalls[j].line < sensLine; j++) {
|
|
243
|
+
const r = allCalls[j];
|
|
244
|
+
if (!isRejection(r)) continue;
|
|
245
|
+
const setsStatusOnly = /^Web\.Scotty(?:\.Trans)?\.status$/.test(r.callee);
|
|
246
|
+
if (setsStatusOnly && !haltsAfter(allCalls[i].line)) continue; // an identifier carries its enclosing node's line, so position is judged from the credential read
|
|
247
|
+
inlineIdx = j; inlineKind = k; inlineLine = r.line; inlineCallee = allCalls[i].callee; break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
224
250
|
const principals = new Set();
|
|
225
251
|
for (const a of assigns) if (a.source.kind === 'call' && isGuardCall(a.source)) principals.add(a.target);
|
|
226
252
|
// ids read from the request
|
|
@@ -232,7 +258,7 @@ function handlerFacts(ctx, fn) {
|
|
|
232
258
|
if (nm && /id$/i.test(nm)) idVars.add(a.target);
|
|
233
259
|
}
|
|
234
260
|
}
|
|
235
|
-
return { ordered, guardIdx, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
|
|
261
|
+
return { ordered, guardIdx, inlineIdx, inlineKind, inlineLine, inlineCallee, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
|
|
236
262
|
}
|
|
237
263
|
|
|
238
264
|
function hasOwnership(ctx, facts) {
|
|
@@ -277,6 +303,8 @@ function analyzeRoute(ctx, route, handlerFn, opts) {
|
|
|
277
303
|
R.auth = { status: 'authenticated', kind: (sub.kinds && sub.kinds[0]) || null, evidence: [{ kind: 'handler-guard', callee: g.callee, line: facts.ordered[facts.guardIdx].line }] };
|
|
278
304
|
(sub.kinds || []).forEach((k) => R.credentialKinds.add(k));
|
|
279
305
|
}
|
|
306
|
+
} else if (facts.inlineIdx >= 0) {
|
|
307
|
+
R.auth = { status: 'authenticated', kind: facts.inlineKind, evidence: [{ kind: 'inline-guard', callee: facts.inlineCallee, line: facts.inlineLine }] };
|
|
280
308
|
} else R.auth = { status: 'none', kind: null, evidence: [] };
|
|
281
309
|
// role
|
|
282
310
|
const roleCall = facts.ordered.find((o) => ROLE_CALL.test(o.call.callee || ''));
|
package/src/language/nix-sca.js
CHANGED
|
@@ -102,7 +102,7 @@ export class NixAdvisoryData {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// ── upstream identity ────────────────────────────────────────────────────────
|
|
105
|
-
const HASKELL_ENV_HINTS = ['libraryHaskellDepends', 'setupHaskellDepends', 'executableHaskellDepends', 'isLibrary', 'isExecutable', 'enableSeparateDataOutput', 'compilerName', 'enableLibraryProfiling'];
|
|
105
|
+
export const HASKELL_ENV_HINTS = ['libraryHaskellDepends', 'setupHaskellDepends', 'executableHaskellDepends', 'isLibrary', 'isExecutable', 'enableSeparateDataOutput', 'compilerName', 'enableLibraryProfiling'];
|
|
106
106
|
function purlFromUrl(u) {
|
|
107
107
|
const m = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:\/|$)/.exec(u || '');
|
|
108
108
|
if (m) return { purl: `pkg:github/${m[1].toLowerCase()}/${m[2].toLowerCase()}`, host: 'github' };
|
|
@@ -280,9 +280,14 @@ export function matchNixVulnerabilities(opts = {}) {
|
|
|
280
280
|
if (!data) { push({ status: 'unknown', reason: 'no advisory feed was supplied: absence of a match is not a clean result' }); summary.unmapped++; continue; }
|
|
281
281
|
if (ident.versionRejected && !ident.haskell && !(node.upstream && node.upstream.rev)) { push({ status: 'unknown', reason: `the version cannot be queried: ${ident.versionRejected}` }); summary.unmapped++; continue; }
|
|
282
282
|
|
|
283
|
-
let matched = false; let mapped = false;
|
|
283
|
+
let matched = false; let mapped = false; let hackageGap = null;
|
|
284
284
|
// Hackage (wrapped Haskell packages): reuse the PVP matcher
|
|
285
|
-
if (ident.haskell && data.hackage && ident.version && parsePvp(ident.version)) {
|
|
285
|
+
if (ident.haskell && data.hackage && ident.version && parsePvp(ident.version) && data.hackage.coverage(node.pname) !== 'covered') {
|
|
286
|
+
// A feed that never looked up this package (or looked too long ago) says nothing about it: that is unknown, not "no advisory matches".
|
|
287
|
+
hackageGap = data.hackage.coverage(node.pname) === 'stale'
|
|
288
|
+
? 'the Hackage advisory feed last covered this package longer ago than its age limit: the absence of a match is not a clean result'
|
|
289
|
+
: 'the Hackage advisory feed never covered this package: the absence of a match is not a clean result';
|
|
290
|
+
} else if (ident.haskell && data.hackage && ident.version && parsePvp(ident.version)) {
|
|
286
291
|
mapped = true;
|
|
287
292
|
for (const { adv, aff } of data.hackage.forPackage(node.pname)) {
|
|
288
293
|
if (adv.withdrawn) continue;
|
|
@@ -316,6 +321,7 @@ export function matchNixVulnerabilities(opts = {}) {
|
|
|
316
321
|
}
|
|
317
322
|
}
|
|
318
323
|
if (matched) summary.matched++;
|
|
324
|
+
else if (hackageGap) { summary.unmapped++; push({ status: 'unknown', reason: hackageGap }, { identityMapped: false, feedCoverage: 'incomplete' }); }
|
|
319
325
|
else if (mapped) { summary.mappedNoMatch++; push({ status: 'not-affected', reason: 'mapped to an upstream identity and no advisory in the supplied feed matches this version' }, { identityMapped: true }); }
|
|
320
326
|
else { summary.unmapped++; push({ status: 'unknown', reason: ident.ambiguous ? 'the upstream identity is ambiguous: no verdict' : 'the upstream identity could not be mapped to anything an advisory is keyed by' }, { identityMapped: false }); }
|
|
321
327
|
}
|