@qa-ai-stlc/runner-security 0.0.0-stage → 1.7.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.
Files changed (94) hide show
  1. package/README.md +52 -2
  2. package/dist/checks/authz-check.d.ts +9 -0
  3. package/dist/checks/authz-check.d.ts.map +1 -0
  4. package/dist/checks/authz-check.js +100 -0
  5. package/dist/checks/authz-check.js.map +1 -0
  6. package/dist/checks/code-check-support.d.ts +9 -0
  7. package/dist/checks/code-check-support.d.ts.map +1 -0
  8. package/dist/checks/code-check-support.js +19 -0
  9. package/dist/checks/code-check-support.js.map +1 -0
  10. package/dist/checks/configuration-check.d.ts +10 -0
  11. package/dist/checks/configuration-check.d.ts.map +1 -0
  12. package/dist/checks/configuration-check.js +128 -0
  13. package/dist/checks/configuration-check.js.map +1 -0
  14. package/dist/checks/cookies-check.d.ts +10 -0
  15. package/dist/checks/cookies-check.d.ts.map +1 -0
  16. package/dist/checks/cookies-check.js +113 -0
  17. package/dist/checks/cookies-check.js.map +1 -0
  18. package/dist/checks/cors-check.d.ts +10 -0
  19. package/dist/checks/cors-check.d.ts.map +1 -0
  20. package/dist/checks/cors-check.js +55 -0
  21. package/dist/checks/cors-check.js.map +1 -0
  22. package/dist/checks/csrf-check.d.ts +10 -0
  23. package/dist/checks/csrf-check.d.ts.map +1 -0
  24. package/dist/checks/csrf-check.js +108 -0
  25. package/dist/checks/csrf-check.js.map +1 -0
  26. package/dist/checks/dependencies-check.d.ts +10 -0
  27. package/dist/checks/dependencies-check.d.ts.map +1 -0
  28. package/dist/checks/dependencies-check.js +160 -0
  29. package/dist/checks/dependencies-check.js.map +1 -0
  30. package/dist/checks/encoding-check.d.ts +10 -0
  31. package/dist/checks/encoding-check.d.ts.map +1 -0
  32. package/dist/checks/encoding-check.js +67 -0
  33. package/dist/checks/encoding-check.js.map +1 -0
  34. package/dist/checks/errors-check.d.ts +8 -0
  35. package/dist/checks/errors-check.d.ts.map +1 -0
  36. package/dist/checks/errors-check.js +60 -0
  37. package/dist/checks/errors-check.js.map +1 -0
  38. package/dist/checks/headers-check.d.ts +9 -0
  39. package/dist/checks/headers-check.d.ts.map +1 -0
  40. package/dist/checks/headers-check.js +110 -0
  41. package/dist/checks/headers-check.js.map +1 -0
  42. package/dist/checks/secrets-check.d.ts +10 -0
  43. package/dist/checks/secrets-check.d.ts.map +1 -0
  44. package/dist/checks/secrets-check.js +113 -0
  45. package/dist/checks/secrets-check.js.map +1 -0
  46. package/dist/checks/session-check.d.ts +10 -0
  47. package/dist/checks/session-check.d.ts.map +1 -0
  48. package/dist/checks/session-check.js +87 -0
  49. package/dist/checks/session-check.js.map +1 -0
  50. package/dist/checks/session-pages.d.ts +11 -0
  51. package/dist/checks/session-pages.d.ts.map +1 -0
  52. package/dist/checks/session-pages.js +25 -0
  53. package/dist/checks/session-pages.js.map +1 -0
  54. package/dist/cookie-flags.d.ts +14 -0
  55. package/dist/cookie-flags.d.ts.map +1 -0
  56. package/dist/cookie-flags.js +28 -0
  57. package/dist/cookie-flags.js.map +1 -0
  58. package/dist/default-checks.d.ts +4 -0
  59. package/dist/default-checks.d.ts.map +1 -0
  60. package/dist/default-checks.js +28 -0
  61. package/dist/default-checks.js.map +1 -0
  62. package/dist/fetch-page.d.ts +19 -0
  63. package/dist/fetch-page.d.ts.map +1 -0
  64. package/dist/fetch-page.js +36 -0
  65. package/dist/fetch-page.js.map +1 -0
  66. package/dist/finding.d.ts +8 -0
  67. package/dist/finding.d.ts.map +1 -0
  68. package/dist/finding.js +14 -0
  69. package/dist/finding.js.map +1 -0
  70. package/dist/identity-sessions.d.ts +42 -0
  71. package/dist/identity-sessions.d.ts.map +1 -0
  72. package/dist/identity-sessions.js +102 -0
  73. package/dist/identity-sessions.js.map +1 -0
  74. package/dist/index.d.ts +8 -0
  75. package/dist/index.d.ts.map +1 -0
  76. package/dist/index.js +10 -0
  77. package/dist/index.js.map +1 -0
  78. package/dist/security-audit.d.ts +30 -0
  79. package/dist/security-audit.d.ts.map +1 -0
  80. package/dist/security-audit.js +173 -0
  81. package/dist/security-audit.js.map +1 -0
  82. package/dist/security-check.d.ts +30 -0
  83. package/dist/security-check.d.ts.map +1 -0
  84. package/dist/security-check.js +4 -0
  85. package/dist/security-check.js.map +1 -0
  86. package/dist/security-probe.d.ts +56 -0
  87. package/dist/security-probe.d.ts.map +1 -0
  88. package/dist/security-probe.js +128 -0
  89. package/dist/security-probe.js.map +1 -0
  90. package/dist/source-checkout.d.ts +56 -0
  91. package/dist/source-checkout.d.ts.map +1 -0
  92. package/dist/source-checkout.js +140 -0
  93. package/dist/source-checkout.js.map +1 -0
  94. package/package.json +36 -4
package/README.md CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
1
+ # @qa-ai-stlc/runner-security
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The black-box security audit engine. It is an on-demand capability outside the regular pipeline:
4
+ it runs only when `testing.security` is `in-scope` and only after the operator approved an
5
+ authorization (environment, checks, identities, prohibited actions, the exact mutations allowed,
6
+ rate limit and request budget). The approval is bound to the authorization's content and to the
7
+ environment's address and allowlist, so editing either withdraws it.
8
+
9
+ Every check reaches the application through one probe, which holds the authorized limits: nothing
10
+ outside the authorized origin, no request that is not a read unless the authorization names that
11
+ exact method and path, no faster than the rate limit, no more than the request budget, and no
12
+ redirect followed. Every request, sent or refused, is logged in the audit result.
13
+
14
+ ## Checks
15
+
16
+ | Class | What it looks at |
17
+ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
18
+ | `headers` | Content-Security-Policy, nosniff, framing protection, HSTS (HTTPS only), Referrer-Policy, a framework or version named in `Server` / `X-Powered-By` |
19
+ | `cookies` | HttpOnly, Secure (HTTPS only) and SameSite on every cookie set for an anonymous visitor; cookie values are never recorded. A session cookie is usually set only by a sign-in, so without a cookie before sign-in this is `skipped` |
20
+ | `cors` | Whether a foreign origin or the `null` origin is allowed, and whether with credentials |
21
+ | `errors` | Stack traces, database errors and framework banners in a missing-page and a malformed-escape response |
22
+ | `encoding` | Whether the usual query parameters are written into a page unencoded. A clean result is `uncertain`: the parameters the application really reads are not discovered |
23
+
24
+ | `csrf` | Replays each mutation the authorization names, with the identity's session, a foreign `Origin` and no anti-forgery token. The authorization should carry a complete request body the operator vouches for, otherwise a refusal is `uncertain`. The sign-out path is never replayed |
25
+ | `authz` | Opens each restricted route as the identity that may, as the one that must not (role `low`), and with no session. First confirms the low identity's session is alive, because a dead one is refused everywhere |
26
+ | `session` | Signs out and replays the old session cookie on a page that tells signed-in from anonymous; it ends the identity's session, so it runs after the checks that use it |
27
+
28
+ Identities sign in through the saved storage state under `.qa/auth/`, or through `authenticate()` (a scripted login whose page must be inside the authorized origin, or an attached Chrome). An identity that cannot sign in makes the checks that need it `blocked`, not `passed`.
29
+
30
+ ## Code-assisted level
31
+
32
+ When `source.path` is set, three more classes read the application's source checkout. They run only
33
+ when the authorization names them, and they only read: the checkout is exposed without any write,
34
+ directories such as `node_modules`, `dist` and `.git` are left out, and binary or oversized files are
35
+ counted but not read. Findings carry `codeReferences` (project-relative file and line), which the
36
+ defect draft lists under "Code references".
37
+
38
+ | Class | What it looks at |
39
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
40
+ | `dependencies` | `npm audit` over the lockfile (found at or above `source.path`; a workspace is audited on its own). It sends package names and versions to the configured package registry, so naming the class is also consent to that. Other package managers are `skipped`; an audit that cannot run is `blocked` |
41
+ | `secrets` | Provider key shapes, private key headers, JWTs, bearer tokens, password and API key assignments, and secrets with a literal value or literal fallback. Only the kind of secret and where it is are reported, never the value. A match in test code or sample data has low confidence |
42
+ | `configuration` | Debug and test-only routes, debug mode, cookies without HttpOnly or Secure, disabled certificate verification, a wildcard CORS origin. A setting beside an environment check (`NODE_ENV`) is not reported. These are patterns in text, so a clean result is `uncertain` and test code is not examined |
43
+
44
+ Without `source.path`, or when it points at nothing, these classes are `skipped` with a note and the
45
+ audit logs a notice that it ran black-box only.
46
+
47
+ A check ends `passed`, `failed`, `skipped`, `blocked` or `uncertain`; the last three are never
48
+ reported as `passed`, and an audit with a blocked check or an exhausted budget is `partial`.
49
+ Findings become tracker-neutral defect drafts with `category: security`; accepting them is the
50
+ ordinary defect gate.
51
+
52
+ Part of [QA-AI-STLC](https://github.com/Klim-101/QA-AI-STLC). See the repository root for
53
+ license, contributing and security information.
@@ -0,0 +1,9 @@
1
+ import type { SecurityCheck } from '../security-check.js';
2
+ /**
3
+ * Asks for each restricted route the authorization names, as the identity that may reach it, as
4
+ * the identity that must not, and with no session at all. A route the privileged identity cannot
5
+ * reach itself cannot be judged and makes the check `uncertain`. Only GET requests are sent.
6
+ * Reaching the route is read as: a 2xx answer on the page that was asked for, not a sign-in page.
7
+ */
8
+ export declare const authzCheck: SecurityCheck;
9
+ //# sourceMappingURL=authz-check.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authz-check.d.ts","sourceRoot":"","sources":["../../src/checks/authz-check.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAA4C,MAAM,sBAAsB,CAAC;AAYpG;;;;;GAKG;AACH,eAAO,MAAM,UAAU,EAAE,aA8DxB,CAAC"}
@@ -0,0 +1,100 @@
1
+ // Copyright The QA-AI-STLC Authors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { fetchPage } from '../fetch-page.js';
4
+ import { buildFinding } from '../finding.js';
5
+ import { isAccepted, reachedRoute, sessionHeaders } from './session-pages.js';
6
+ const BASELINE_PAGE = '/';
7
+ function unavailable(input, whatIsMissing) {
8
+ const failures = input.identities.describeFailures();
9
+ return failures === undefined
10
+ ? { status: 'skipped', note: whatIsMissing, findings: [] }
11
+ : { status: 'blocked', note: `${whatIsMissing} (${failures})`, findings: [] };
12
+ }
13
+ /**
14
+ * Asks for each restricted route the authorization names, as the identity that may reach it, as
15
+ * the identity that must not, and with no session at all. A route the privileged identity cannot
16
+ * reach itself cannot be judged and makes the check `uncertain`. Only GET requests are sent.
17
+ * Reaching the route is read as: a 2xx answer on the page that was asked for, not a sign-in page.
18
+ */
19
+ export const authzCheck = {
20
+ checkClass: 'authz',
21
+ run: async (input) => {
22
+ const { probe, authorization, identities } = input;
23
+ const { baseUrl } = authorization.environment;
24
+ if (authorization.restrictedRoutes.length === 0) {
25
+ return { status: 'skipped', note: 'The authorization names no restricted route.', findings: [] };
26
+ }
27
+ const low = identities.byRole('low');
28
+ const high = identities.byRole('high');
29
+ if (low === undefined || high === undefined) {
30
+ return unavailable(input, 'The check needs one signed-in identity with role low and one with role high');
31
+ }
32
+ // A refusal only means something if the session was alive to be refused with. A signed-out or
33
+ // expired session is turned away from everything, so without this it would read as a pass.
34
+ const baseline = await fetchPage(probe, baseUrl, BASELINE_PAGE, { headers: sessionHeaders(low) });
35
+ if (!isAccepted(baseline)) {
36
+ return {
37
+ status: 'uncertain',
38
+ note: `The "${low.name}" session was not accepted on ${BASELINE_PAGE}, so a refusal elsewhere would prove nothing.`,
39
+ findings: [],
40
+ };
41
+ }
42
+ const findings = [];
43
+ const unverified = [];
44
+ for (const route of authorization.restrictedRoutes) {
45
+ const asHigh = await fetchPage(probe, baseUrl, route, { headers: sessionHeaders(high) });
46
+ if (!reachedRoute(asHigh, route)) {
47
+ unverified.push(route);
48
+ continue;
49
+ }
50
+ const asLow = await fetchPage(probe, baseUrl, route, { headers: sessionHeaders(low) });
51
+ const anonymous = await fetchPage(probe, baseUrl, route);
52
+ if (reachedRoute(asLow, route)) {
53
+ findings.push(finding(route, 'low-privilege', `"${low.name}" (role low)`, asLow, asHigh));
54
+ }
55
+ if (reachedRoute(anonymous, route)) {
56
+ findings.push(finding(route, 'anonymous', 'a visitor who is not signed in', anonymous, asHigh));
57
+ }
58
+ }
59
+ if (findings.length === 0 && unverified.length > 0) {
60
+ return {
61
+ status: 'uncertain',
62
+ note: `The privileged identity could not reach ${unverified.join(', ')}, so access to it could not be judged.`,
63
+ findings,
64
+ };
65
+ }
66
+ return {
67
+ status: 'passed',
68
+ note: unverified.length > 0
69
+ ? `Not judged, because the privileged identity could not reach them: ${unverified.join(', ')}.`
70
+ : `Checked ${String(authorization.restrictedRoutes.length)} restricted route(s).`,
71
+ findings,
72
+ };
73
+ },
74
+ };
75
+ function finding(route, who, description, actual, privileged) {
76
+ // The same page for both is conclusive; a different page that is still a 2xx is likely but could
77
+ // be a public variant of the route.
78
+ const isSameContent = actual.response.bodyText === privileged.response.bodyText;
79
+ return buildFinding({
80
+ idParts: ['authz', route, who],
81
+ checkClass: 'authz',
82
+ riskArea: who === 'anonymous' ? 'Access control: authentication' : 'Access control: privilege escalation',
83
+ confidence: isSameContent ? 'high' : 'medium',
84
+ severityProposal: 'critical',
85
+ title: who === 'anonymous'
86
+ ? `${route} can be opened without signing in`
87
+ : `${route} can be opened by an identity that should not reach it`,
88
+ steps: [
89
+ `Request ${route} as an identity that is allowed to reach it`,
90
+ `Request ${route} as ${description}`,
91
+ 'Compare the two answers',
92
+ ],
93
+ expectedResult: `${route} refuses ${description}, or sends it to sign in`,
94
+ actualResult: `${description} received the page (status ${String(actual.response.status)})${isSameContent ? ', identical to what the privileged identity sees' : ''}`,
95
+ remediation: `Check the caller's role on the server before serving ${route}, not only in the navigation.`,
96
+ regressionCheck: `Assert that ${route} answers ${description} with 401 or 403, or a redirect to sign in`,
97
+ requestIndexes: [actual.response.requestIndex, privileged.response.requestIndex],
98
+ });
99
+ }
100
+ //# sourceMappingURL=authz-check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authz-check.js","sourceRoot":"","sources":["../../src/checks/authz-check.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ export declare function isTestOrFixturePath(path: string): boolean;
2
+ export interface SourceLine {
3
+ readonly number: number;
4
+ readonly text: string;
5
+ }
6
+ export declare function scannableLines(text: string): readonly SourceLine[];
7
+ /** A finding cites at most this many lines of one file: the file is the subject, not every match. */
8
+ export declare const MAX_CITED_LINES_PER_FILE = 5;
9
+ //# sourceMappingURL=code-check-support.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"code-check-support.d.ts","sourceRoot":"","sources":["../../src/checks/code-check-support.ts"],"names":[],"mappings":"AAQA,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEzD;AAKD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,UAAU,EAAE,CAKlE;AAED,qGAAqG;AACrG,eAAO,MAAM,wBAAwB,IAAI,CAAC"}
@@ -0,0 +1,19 @@
1
+ // Copyright The QA-AI-STLC Authors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Test code and sample data are not what reaches production, and the secrets and settings in them
4
+ // are placeholders far more often than not, so findings there are weaker or left out.
5
+ const TEST_PATH_PATTERN = /(?:^|\/)(?:tests?|__tests__|__mocks__|fixtures?|mocks?|examples?|e2e|spec)\/|\.(?:test|spec)\.[a-z]+$/iu;
6
+ export function isTestOrFixturePath(path) {
7
+ return TEST_PATH_PATTERN.test(path);
8
+ }
9
+ // Longer lines are minified or generated output, where a line number would point nowhere.
10
+ const MAX_SCANNED_LINE_LENGTH = 1000;
11
+ export function scannableLines(text) {
12
+ return text
13
+ .split(/\r?\n/u)
14
+ .map((lineText, index) => ({ number: index + 1, text: lineText }))
15
+ .filter(({ text: lineText }) => lineText.length > 0 && lineText.length <= MAX_SCANNED_LINE_LENGTH);
16
+ }
17
+ /** A finding cites at most this many lines of one file: the file is the subject, not every match. */
18
+ export const MAX_CITED_LINES_PER_FILE = 5;
19
+ //# sourceMappingURL=code-check-support.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"code-check-support.js","sourceRoot":"","sources":["../../src/checks/code-check-support.ts"],"names":[],"mappings":"AAAA,mCAAmC;AACnC,sCAAsC;AAEtC,kGAAkG;AAClG,sFAAsF;AACtF,MAAM,iBAAiB,GACrB,yGAAyG,CAAC;AAE5G,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,OAAO,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACtC,CAAC;AAED,0FAA0F;AAC1F,MAAM,uBAAuB,GAAG,IAAI,CAAC;AAOrC,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,OAAO,IAAI;SACR,KAAK,CAAC,QAAQ,CAAC;SACf,GAAG,CAAC,CAAC,QAAQ,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,GAAG,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC;SACjE,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,QAAQ,CAAC,MAAM,IAAI,uBAAuB,CAAC,CAAC;AACvG,CAAC;AAED,qGAAqG;AACrG,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { SecurityCheck } from '../security-check.js';
2
+ /**
3
+ * Looks in the source checkout for settings and routes that should not reach a production build:
4
+ * debug and test-only endpoints, debug mode, cookies without HttpOnly or Secure, disabled
5
+ * certificate verification, a wildcard CORS origin. Test code is not examined. These are patterns
6
+ * in text, not an analysis of what runs, so a clean result is `uncertain` and a match keeps a
7
+ * confidence that says how often the pattern is wrong.
8
+ */
9
+ export declare const configurationCheck: SecurityCheck;
10
+ //# sourceMappingURL=configuration-check.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"configuration-check.d.ts","sourceRoot":"","sources":["../../src/checks/configuration-check.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAwB,MAAM,sBAAsB,CAAC;AAuFhF;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,EAAE,aAsDhC,CAAC"}
@@ -0,0 +1,128 @@
1
+ // Copyright The QA-AI-STLC Authors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { buildFinding } from '../finding.js';
4
+ import { MAX_CITED_LINES_PER_FILE, isTestOrFixturePath, scannableLines } from './code-check-support.js';
5
+ // A debug or test-only route, by the names such routes usually carry. The route prefix is anchored
6
+ // to the start of the path so `/debuggable-things` is not mistaken for `/debug`.
7
+ const DEBUG_ROUTE = /(?:\.(?:get|post|put|patch|delete|all|use)\(|@\w+\.(?:route|get|post)\()\s*["'`]\/(?:_{0,2}(?:debug|test|testing|reset|seed|phpinfo|console)|test-only|internal\/test|heapdump)(?:[/"'`?]|$)/iu;
8
+ const RULES = [
9
+ {
10
+ name: 'debug-endpoint',
11
+ title: 'A debug or test-only endpoint is registered',
12
+ riskArea: 'Debug and test-only code in production builds',
13
+ regex: DEBUG_ROUTE,
14
+ severity: 'major',
15
+ confidence: 'medium',
16
+ remediation: 'Register debug and test-only routes only when the environment is not production, or remove them.',
17
+ },
18
+ {
19
+ name: 'cookie-http-only-disabled',
20
+ title: 'Cookies are configured without HttpOnly',
21
+ riskArea: 'Session management configuration',
22
+ regex: /\bhttpOnly["']?\s*[:=]\s*false\b/iu,
23
+ severity: 'major',
24
+ confidence: 'high',
25
+ remediation: 'Leave HttpOnly on for session cookies so a script injected into a page cannot read them.',
26
+ },
27
+ {
28
+ name: 'cookie-secure-disabled',
29
+ title: 'Cookies are configured without Secure',
30
+ riskArea: 'Session management configuration',
31
+ regex: /\bsecure["']?\s*[:=]\s*false\b/iu,
32
+ severity: 'major',
33
+ confidence: 'medium',
34
+ remediation: 'Set Secure on session cookies so they are never sent over plain HTTP.',
35
+ },
36
+ {
37
+ name: 'debug-mode-enabled',
38
+ title: 'Debug mode is switched on in configuration',
39
+ riskArea: 'Debug and test-only code in production builds',
40
+ regex: /\bdebug["']?\s*[:=]\s*(?:true|True)\b|\bDEBUG\s*=\s*True\b|\.run\([^)]*\bdebug\s*=\s*True/u,
41
+ severity: 'minor',
42
+ confidence: 'low',
43
+ remediation: 'Switch debug mode off in production configuration, or derive it from the environment.',
44
+ },
45
+ {
46
+ name: 'tls-verification-disabled',
47
+ title: 'Certificate verification is switched off',
48
+ riskArea: 'Transport security configuration',
49
+ regex: /\brejectUnauthorized["']?\s*[:=]\s*false\b|NODE_TLS_REJECT_UNAUTHORIZED["']?\s*[:=]\s*["']?0\b|\bverify\s*=\s*False\b/u,
50
+ severity: 'major',
51
+ confidence: 'medium',
52
+ remediation: 'Keep certificate verification on; trust a private authority explicitly instead.',
53
+ },
54
+ {
55
+ name: 'cors-any-origin',
56
+ title: 'Any origin is allowed by the CORS configuration',
57
+ riskArea: 'Cross-origin access configuration',
58
+ regex: /\borigin["']?\s*:\s*["'`]\*["'`]|Access-Control-Allow-Origin["']?\s*[:,=]\s*["'`]\*["'`]/iu,
59
+ severity: 'minor',
60
+ confidence: 'medium',
61
+ remediation: 'Allow the origins that need access by name, and never combine a wildcard with credentials.',
62
+ },
63
+ ];
64
+ const CONFIGURATION_EXTENSIONS = /\.(?:[cm]?[jt]sx?|py|rb|php|java|kt|cs|go|json|ya?ml|toml|ini|env|properties|config|xml)$/iu;
65
+ // A setting wrapped in an environment test (`if (process.env.NODE_ENV !== 'production')`) is the
66
+ // intended way to keep development behavior out of a production build. The words are looked for in
67
+ // the same line and the few above it; a deeper nesting is a limitation, so those findings keep their
68
+ // stated confidence and the remediation says to guard the setting.
69
+ const ENVIRONMENT_GUARD = /NODE_ENV|APP_ENV|\bisProd|\bisDev|\bis_prod|\bis_dev|FLASK_ENV|RAILS_ENV/u;
70
+ const GUARD_WINDOW_LINES = 6;
71
+ /**
72
+ * Looks in the source checkout for settings and routes that should not reach a production build:
73
+ * debug and test-only endpoints, debug mode, cookies without HttpOnly or Secure, disabled
74
+ * certificate verification, a wildcard CORS origin. Test code is not examined. These are patterns
75
+ * in text, not an analysis of what runs, so a clean result is `uncertain` and a match keeps a
76
+ * confidence that says how often the pattern is wrong.
77
+ */
78
+ export const configurationCheck = {
79
+ checkClass: 'configuration',
80
+ run: async ({ source }) => {
81
+ if (source === undefined) {
82
+ return { status: 'skipped', note: 'No source.path is configured.', findings: [] };
83
+ }
84
+ const scan = await source.scan();
85
+ const findings = [];
86
+ let examinedCount = 0;
87
+ for (const file of scan.files) {
88
+ if (isTestOrFixturePath(file.path) || !CONFIGURATION_EXTENSIONS.test(file.path)) {
89
+ continue;
90
+ }
91
+ examinedCount += 1;
92
+ const lines = scannableLines(file.text);
93
+ const allLines = file.text.split(/\r?\n/u);
94
+ for (const rule of RULES) {
95
+ const matched = lines.filter((line) => rule.regex.test(line.text) &&
96
+ !allLines
97
+ .slice(Math.max(0, line.number - 1 - GUARD_WINDOW_LINES), line.number)
98
+ .some((candidate) => ENVIRONMENT_GUARD.test(candidate)));
99
+ if (matched.length === 0) {
100
+ continue;
101
+ }
102
+ findings.push(buildFinding({
103
+ idParts: ['configuration', rule.name, file.path],
104
+ checkClass: 'configuration',
105
+ riskArea: rule.riskArea,
106
+ confidence: rule.confidence,
107
+ severityProposal: rule.severity,
108
+ title: `${rule.title} in ${file.path}`,
109
+ steps: [`Open ${file.path}`, `Read line ${String(matched[0]?.number)}`],
110
+ expectedResult: 'Production builds carry no debug or test-only behavior and use safe defaults',
111
+ actualResult: `${file.path} contains this setting on ${String(matched.length)} line(s) with no environment check beside it`,
112
+ remediation: rule.remediation,
113
+ regressionCheck: `Assert that ${file.path} no longer matches this setting, or that it is guarded by an environment check`,
114
+ requestIndexes: [],
115
+ codeReferences: matched
116
+ .slice(0, MAX_CITED_LINES_PER_FILE)
117
+ .map((line) => ({ path: file.path, line: line.number })),
118
+ }));
119
+ }
120
+ }
121
+ return {
122
+ status: 'uncertain',
123
+ note: `Matched ${String(RULES.length)} patterns in ${String(examinedCount)} configuration or code file(s). Patterns in text do not show what a production build runs.${scan.isTruncated ? ' The file limit stopped the scan early.' : ''}`,
124
+ findings,
125
+ };
126
+ },
127
+ };
128
+ //# sourceMappingURL=configuration-check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"configuration-check.js","sourceRoot":"","sources":["../../src/checks/configuration-check.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ import type { SecurityCheck } from '../security-check.js';
2
+ /**
3
+ * Checks the protective attributes (HttpOnly, Secure, SameSite) of every cookie the application
4
+ * sets for an anonymous visitor and of every cookie a signed-in identity's session holds, which
5
+ * is where a session cookie shows up. A cookie value is never read into a finding. The attributes are
6
+ * facts about the header, so confidence is high; severity is higher for a cookie whose name looks
7
+ * like a session, because that is the one worth stealing.
8
+ */
9
+ export declare const cookiesCheck: SecurityCheck;
10
+ //# sourceMappingURL=cookies-check.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookies-check.d.ts","sourceRoot":"","sources":["../../src/checks/cookies-check.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,aAAa,EAAwB,MAAM,sBAAsB,CAAC;AA+ChF;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,EAAE,aA8E1B,CAAC"}
@@ -0,0 +1,113 @@
1
+ // Copyright The QA-AI-STLC Authors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { looksLikeSessionCookie, parseSetCookie } from '../cookie-flags.js';
4
+ import { fetchPage } from '../fetch-page.js';
5
+ import { buildFinding } from '../finding.js';
6
+ // Pages a visitor reaches before signing in. A session cookie is usually set only by a sign-in, so
7
+ // the cookies an anonymous visitor receives are checked here and the ones a signed-in identity's
8
+ // browser session holds are checked from its saved state.
9
+ const PAGE_PATHS = ['/', '/login'];
10
+ const RULES = [
11
+ {
12
+ flag: 'httponly',
13
+ isViolated: (cookie) => !cookie.isHttpOnly,
14
+ explain: (name) => `The "${name}" cookie can be read by scripts running in the page`,
15
+ remediation: 'Set HttpOnly on the cookie.',
16
+ regression: 'Assert that the Set-Cookie header of the cookie contains HttpOnly',
17
+ },
18
+ {
19
+ flag: 'secure',
20
+ isViolated: (cookie, isHttps) => isHttps && !cookie.isSecure,
21
+ explain: (name) => `The "${name}" cookie can be sent over an unencrypted connection`,
22
+ remediation: 'Set Secure on the cookie.',
23
+ regression: 'Assert that the Set-Cookie header of the cookie contains Secure',
24
+ },
25
+ {
26
+ flag: 'samesite',
27
+ isViolated: (cookie) => cookie.sameSite === undefined || cookie.sameSite === 'none',
28
+ explain: (name) => `The "${name}" cookie is sent on cross-site requests`,
29
+ remediation: 'Set SameSite=Lax, or Strict where the application allows it.',
30
+ regression: 'Assert that the Set-Cookie header of the cookie names SameSite=Lax or Strict',
31
+ },
32
+ ];
33
+ /**
34
+ * Checks the protective attributes (HttpOnly, Secure, SameSite) of every cookie the application
35
+ * sets for an anonymous visitor and of every cookie a signed-in identity's session holds, which
36
+ * is where a session cookie shows up. A cookie value is never read into a finding. The attributes are
37
+ * facts about the header, so confidence is high; severity is higher for a cookie whose name looks
38
+ * like a session, because that is the one worth stealing.
39
+ */
40
+ export const cookiesCheck = {
41
+ checkClass: 'cookies',
42
+ run: async ({ probe, authorization, identities }) => {
43
+ const { baseUrl } = authorization.environment;
44
+ const isHttps = new URL(baseUrl).protocol === 'https:';
45
+ const observed = new Map();
46
+ for (const path of PAGE_PATHS) {
47
+ const page = await fetchPage(probe, baseUrl, path);
48
+ for (const response of page.chain) {
49
+ for (const header of response.setCookies) {
50
+ const flags = parseSetCookie(header);
51
+ if (flags !== undefined) {
52
+ observed.set(flags.name, {
53
+ flags,
54
+ requestIndex: response.requestIndex,
55
+ holder: 'anonymous visitors',
56
+ });
57
+ }
58
+ }
59
+ }
60
+ }
61
+ for (const session of identities.all()) {
62
+ for (const flags of session.cookies) {
63
+ observed.set(flags.name, {
64
+ flags,
65
+ requestIndex: undefined,
66
+ holder: `the signed-in identity "${session.name}"`,
67
+ });
68
+ }
69
+ }
70
+ const failures = identities.describeFailures();
71
+ if (observed.size === 0) {
72
+ return failures === undefined
73
+ ? {
74
+ status: 'skipped',
75
+ note: 'The application set no cookie for an anonymous visitor and no identity was signed in to hold a session cookie.',
76
+ findings: [],
77
+ }
78
+ : {
79
+ status: 'blocked',
80
+ note: `No cookie was seen, and an identity could not sign in (${failures})`,
81
+ findings: [],
82
+ };
83
+ }
84
+ const findings = [];
85
+ for (const { flags, requestIndex, holder } of observed.values()) {
86
+ const isSession = looksLikeSessionCookie(flags.name);
87
+ for (const rule of RULES.filter((candidate) => candidate.isViolated(flags, isHttps))) {
88
+ findings.push(buildFinding({
89
+ idParts: ['cookies', flags.name, rule.flag],
90
+ checkClass: 'cookies',
91
+ riskArea: 'Session management',
92
+ confidence: 'high',
93
+ severityProposal: isSession && rule.flag !== 'samesite' ? 'major' : 'minor',
94
+ title: `The "${flags.name}" cookie lacks ${rule.flag === 'samesite' ? 'a SameSite policy' : rule.flag === 'httponly' ? 'HttpOnly' : 'Secure'}`,
95
+ steps: requestIndex === undefined
96
+ ? [`Sign in as ${holder}`, 'Read the attributes of the cookies the browser session holds']
97
+ : ['Request the page that sets the cookie', 'Read its Set-Cookie header'],
98
+ expectedResult: `The "${flags.name}" cookie carries the ${rule.flag} protection`,
99
+ actualResult: rule.explain(flags.name),
100
+ remediation: rule.remediation,
101
+ regressionCheck: rule.regression,
102
+ requestIndexes: requestIndex === undefined ? [] : [requestIndex],
103
+ }));
104
+ }
105
+ }
106
+ const note = `Checked ${String(observed.size)} cookie(s) held by anonymous visitors and signed-in identities.`;
107
+ if (failures !== undefined && findings.length === 0) {
108
+ return { status: 'uncertain', note: `${note} An identity could not sign in (${failures}).`, findings };
109
+ }
110
+ return { status: 'passed', note, findings };
111
+ },
112
+ };
113
+ //# sourceMappingURL=cookies-check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookies-check.js","sourceRoot":"","sources":["../../src/checks/cookies-check.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ import type { SecurityCheck } from '../security-check.js';
2
+ /**
3
+ * Sends the landing page request with a foreign `Origin` and reads the CORS answer. Allowing the
4
+ * foreign origin together with credentials lets any site read signed-in responses, which is the
5
+ * serious case; allowing it without credentials only exposes what an anonymous visitor sees.
6
+ * `Access-Control-Allow-Origin: *` is not reported: it is the normal setting of a public resource,
7
+ * and browsers refuse it together with credentials.
8
+ */
9
+ export declare const corsCheck: SecurityCheck;
10
+ //# sourceMappingURL=cors-check.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cors-check.d.ts","sourceRoot":"","sources":["../../src/checks/cors-check.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAwB,MAAM,sBAAsB,CAAC;AAQhF;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,EAAE,aA4CvB,CAAC"}
@@ -0,0 +1,55 @@
1
+ // Copyright The QA-AI-STLC Authors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { fetchPage } from '../fetch-page.js';
4
+ import { buildFinding } from '../finding.js';
5
+ const PAGE_PATH = '/';
6
+ // Origins the application has no reason to trust. `.test` is reserved (RFC 6761) and `null` is what
7
+ // sandboxed documents send, so neither can be a real caller of this application.
8
+ const FOREIGN_ORIGINS = ['https://qa-audit-untrusted.example.test', 'null'];
9
+ /**
10
+ * Sends the landing page request with a foreign `Origin` and reads the CORS answer. Allowing the
11
+ * foreign origin together with credentials lets any site read signed-in responses, which is the
12
+ * serious case; allowing it without credentials only exposes what an anonymous visitor sees.
13
+ * `Access-Control-Allow-Origin: *` is not reported: it is the normal setting of a public resource,
14
+ * and browsers refuse it together with credentials.
15
+ */
16
+ export const corsCheck = {
17
+ checkClass: 'cors',
18
+ run: async ({ probe, authorization }) => {
19
+ const { baseUrl } = authorization.environment;
20
+ const findings = [];
21
+ for (const origin of FOREIGN_ORIGINS) {
22
+ const page = await fetchPage(probe, baseUrl, PAGE_PATH, { headers: { origin } });
23
+ const { headers } = page.response;
24
+ if (headers['access-control-allow-origin'] !== origin) {
25
+ continue;
26
+ }
27
+ const hasCredentials = headers['access-control-allow-credentials']?.toLowerCase() === 'true';
28
+ findings.push(buildFinding({
29
+ idParts: [
30
+ 'cors',
31
+ origin === 'null' ? 'null-origin' : 'reflected-origin',
32
+ hasCredentials ? 'credentials' : 'anonymous',
33
+ ],
34
+ checkClass: 'cors',
35
+ riskArea: 'Cross-origin access',
36
+ confidence: 'high',
37
+ severityProposal: hasCredentials ? 'major' : 'minor',
38
+ title: hasCredentials
39
+ ? `Any origin is allowed to read responses with credentials ("${origin}")`
40
+ : `An untrusted origin is allowed to read responses ("${origin}")`,
41
+ steps: [
42
+ `Request ${PAGE_PATH} with the header Origin: ${origin}`,
43
+ 'Read the Access-Control-Allow-* headers',
44
+ ],
45
+ expectedResult: 'The response does not allow an origin the application has no reason to trust',
46
+ actualResult: `The response allowed "${origin}"${hasCredentials ? ' and sent Access-Control-Allow-Credentials: true' : ''}`,
47
+ remediation: 'Allow only an explicit list of trusted origins; never reflect the request Origin, and never combine it with credentials.',
48
+ regressionCheck: `Assert that a request with Origin: ${origin} gets no Access-Control-Allow-Origin for that origin`,
49
+ requestIndexes: [page.response.requestIndex],
50
+ }));
51
+ }
52
+ return { status: 'passed', note: 'Tried a foreign origin and the null origin.', findings };
53
+ },
54
+ };
55
+ //# sourceMappingURL=cors-check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cors-check.js","sourceRoot":"","sources":["../../src/checks/cors-check.ts"],"names":[],"mappings":"AAAA,mCAAmC;AACnC,sCAAsC;AAGtC,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAG7C,MAAM,SAAS,GAAG,GAAG,CAAC;AAEtB,oGAAoG;AACpG,iFAAiF;AACjF,MAAM,eAAe,GAAsB,CAAC,yCAAyC,EAAE,MAAM,CAAC,CAAC;AAE/F;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,SAAS,GAAkB;IACtC,UAAU,EAAE,MAAM;IAClB,GAAG,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,aAAa,EAAE,EAAiC,EAAE;QACrE,MAAM,EAAE,OAAO,EAAE,GAAG,aAAa,CAAC,WAAW,CAAC;QAC9C,MAAM,QAAQ,GAAsB,EAAE,CAAC;QAEvC,KAAK,MAAM,MAAM,IAAI,eAAe,EAAE,CAAC;YACrC,MAAM,IAAI,GAAG,MAAM,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;YACjF,MAAM,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC,QAAQ,CAAC;YAClC,IAAI,OAAO,CAAC,6BAA6B,CAAC,KAAK,MAAM,EAAE,CAAC;gBACtD,SAAS;YACX,CAAC;YACD,MAAM,cAAc,GAAG,OAAO,CAAC,kCAAkC,CAAC,EAAE,WAAW,EAAE,KAAK,MAAM,CAAC;YAC7F,QAAQ,CAAC,IAAI,CACX,YAAY,CAAC;gBACX,OAAO,EAAE;oBACP,MAAM;oBACN,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,kBAAkB;oBACtD,cAAc,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,WAAW;iBAC7C;gBACD,UAAU,EAAE,MAAM;gBAClB,QAAQ,EAAE,qBAAqB;gBAC/B,UAAU,EAAE,MAAM;gBAClB,gBAAgB,EAAE,cAAc,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO;gBACpD,KAAK,EAAE,cAAc;oBACnB,CAAC,CAAC,8DAA8D,MAAM,IAAI;oBAC1E,CAAC,CAAC,sDAAsD,MAAM,IAAI;gBACpE,KAAK,EAAE;oBACL,WAAW,SAAS,4BAA4B,MAAM,EAAE;oBACxD,yCAAyC;iBAC1C;gBACD,cAAc,EAAE,8EAA8E;gBAC9F,YAAY,EAAE,yBAAyB,MAAM,IAC3C,cAAc,CAAC,CAAC,CAAC,kDAAkD,CAAC,CAAC,CAAC,EACxE,EAAE;gBACF,WAAW,EACT,0HAA0H;gBAC5H,eAAe,EAAE,sCAAsC,MAAM,sDAAsD;gBACnH,cAAc,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC;aAC7C,CAAC,CACH,CAAC;QACJ,CAAC;QACD,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,6CAA6C,EAAE,QAAQ,EAAE,CAAC;IAC7F,CAAC;CACF,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { SecurityCheck } from '../security-check.js';
2
+ /**
3
+ * Replays each mutation the authorization names with a valid session, a foreign `Origin`, and no
4
+ * anti-forgery token. A server that checks for forgery refuses it. Only the mutations the
5
+ * operator named can be sent (the probe enforces it), and a complete request body the operator
6
+ * vouched for is what makes a refusal mean something: an empty body is refused for reasons that
7
+ * have nothing to do with CSRF, so without a body a refusal is `uncertain`, not `passed`.
8
+ */
9
+ export declare const csrfCheck: SecurityCheck;
10
+ //# sourceMappingURL=csrf-check.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf-check.d.ts","sourceRoot":"","sources":["../../src/checks/csrf-check.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAwB,MAAM,sBAAsB,CAAC;AAoBhF;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,EAAE,aAgEvB,CAAC"}